On branch DiscordProfile

Initial commit
This commit is contained in:
EG
2026-07-01 15:15:07 +03:00
commit d4bf750c9e
3125 changed files with 601334 additions and 0 deletions
@@ -0,0 +1,30 @@
"""
discord.ui
~~~~~~~~~~
UI Kit helper for the Discord API
:copyright: (c) 2015-2021 Rapptz & (c) 2021-present Pycord Development
:license: MIT, see LICENSE for more details.
"""
from .action_row import *
from .button import *
from .checkbox import *
from .checkbox_group import *
from .container import *
from .core import *
from .file import *
from .file_upload import *
from .input_text import *
from .item import *
from .label import *
from .media_gallery import *
from .modal import *
from .radio_group import *
from .section import *
from .select import *
from .separator import *
from .text_display import *
from .thumbnail import *
from .view import *
@@ -0,0 +1,436 @@
"""
The MIT License (MIT)
Copyright (c) 2021-present Pycord Development
Permission is hereby granted, free of charge, to any person obtaining a
copy of this software and associated documentation files (the "Software"),
to deal in the Software without restriction, including without limitation
the rights to use, copy, modify, merge, publish, distribute, sublicense,
and/or sell copies of the Software, and to permit persons to whom the
Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
DEALINGS IN THE SOFTWARE.
"""
from __future__ import annotations
from collections.abc import Sequence
from functools import partial
from typing import TYPE_CHECKING, ClassVar, Iterator, Literal, TypeVar, overload
from ..components import ActionRow as ActionRowComponent
from ..components import SelectDefaultValue, SelectOption, _component_factory
from ..enums import ButtonStyle, ChannelType, ComponentType
from ..utils import find, get
from .button import Button
from .file import File
from .item import ItemCallbackType, ViewItem
from .select import Select
__all__ = ("ActionRow",)
if TYPE_CHECKING:
from typing_extensions import Self
from ..emoji import AppEmoji, GuildEmoji
from ..partial_emoji import PartialEmoji, _EmojiTag
from ..types.components import ActionRow as ActionRowPayload
from .view import DesignerView
A = TypeVar("A", bound="ActionRow")
V = TypeVar("V", bound="DesignerView", covariant=True)
class ActionRow(ViewItem[V]):
"""Represents a UI Action Row used in :class:`discord.ui.DesignerView`.
The items supported are as follows:
- :class:`discord.ui.Select`
- :class:`discord.ui.Button`
.. versionadded:: 2.7
Parameters
----------
*items: :class:`ViewItem`
The initial items in this action row.
id: Optional[:class:`int`]
The action's ID.
"""
__item_repr_attributes__: tuple[str, ...] = (
"children",
"id",
)
__row_children_items__: ClassVar[list[ItemCallbackType]] = []
def __init_subclass__(cls) -> None:
children: list[ItemCallbackType] = []
for base in reversed(cls.__mro__):
for member in base.__dict__.values():
if hasattr(member, "__discord_ui_model_type__"):
children.append(member)
cls.__row_children_items__ = children
def __init__(
self,
*items: ViewItem,
id: int | None = None,
):
super().__init__()
self.children: list[ViewItem] = []
self._underlying = self._generate_underlying(id=id)
for func in self.__row_children_items__:
item: ViewItem = func.__discord_ui_model_type__(
**func.__discord_ui_model_kwargs__
)
item.callback = partial(func, self, item)
self.add_item(item)
setattr(self, func.__name__, item)
for i in items:
self.add_item(i)
@property
def items(self) -> list[ViewItem]:
return self.children
@items.setter
def items(self, value: list[ViewItem]) -> None:
self.children = value
def _add_component_from_item(self, item: ViewItem):
self.underlying.children.append(item._generate_underlying())
def _set_components(self, items: list[ViewItem]):
self.underlying.children.clear()
for item in items:
self._add_component_from_item(item)
def _generate_underlying(self, id: int | None = None) -> ActionRowComponent:
super()._generate_underlying(ActionRowComponent)
row = ActionRowComponent._raw_construct(
type=ComponentType.action_row,
id=id or self.id,
children=[],
)
for i in self.children:
row.children.append(i._generate_underlying())
return row
def add_item(self, item: ViewItem) -> Self:
"""Adds an item to the action row.
Parameters
----------
item: :class:`ViewItem`
The item to add to the action row.
Raises
------
TypeError
A :class:`ViewItem` was not passed.
"""
if not isinstance(item, (Select, Button)):
raise TypeError(f"expected Select or Button, not {item.__class__!r}")
if item.row:
raise ValueError(f"{item.__class__!r}.row is not supported in ActionRow")
if self.width + item.width > 5:
raise ValueError(f"Not enough space left on this ActionRow")
item.parent = self
self.children.append(item)
self._add_component_from_item(item)
return self
def remove_item(self, item: ViewItem | str | int) -> Self:
"""Removes an item from the action row. If an int or str is passed, it will remove by Item :attr:`id` or ``custom_id`` respectively.
Parameters
----------
item: Union[:class:`ViewItem`, :class:`int`, :class:`str`]
The item, ``id``, or item ``custom_id`` to remove from the action row.
"""
if isinstance(item, (str, int)):
item = self.get_item(item)
try:
self.children.remove(item)
item.parent = None
except ValueError:
pass
return self
def get_item(self, id: str | int) -> ViewItem | None:
"""Get an item from this action row. Roughly equivalent to `utils.get(row.children, ...)`.
If an ``int`` is provided, the item will be retrieved by ``id``, otherwise by ``custom_id``.
Parameters
----------
id: Union[:class:`str`, :class:`int`]
The id or custom_id of the item to get.
Returns
-------
Optional[:class:`ViewItem`]
The item with the matching ``id`` or ``custom_id`` if it exists.
"""
if not id:
return None
attr = "id" if isinstance(id, int) else "custom_id"
child = find(lambda i: getattr(i, attr, None) == id, self.children)
return child
def add_button(
self,
*,
style: ButtonStyle = ButtonStyle.secondary,
label: str | None = None,
disabled: bool = False,
custom_id: str | None = None,
url: str | None = None,
emoji: str | GuildEmoji | AppEmoji | PartialEmoji | None = None,
sku_id: int | None = None,
id: int | None = None,
) -> Self:
"""Adds a :class:`Button` to the action row.
To append a pre-existing :class:`Button`, use the
:meth:`add_item` method instead.
Parameters
----------
style: :class:`discord.ButtonStyle`
The style of the button.
custom_id: Optional[:class:`str`]
The custom ID of the button that gets received during an interaction.
If this button is for a URL, it does not have a custom ID.
url: Optional[:class:`str`]
The URL this button sends you to.
disabled: :class:`bool`
Whether the button is disabled or not.
label: Optional[:class:`str`]
The label of the button, if any. Maximum of 80 chars.
emoji: Optional[Union[:class:`.PartialEmoji`, :class:`GuildEmoji`, :class:`AppEmoji`, :class:`str`]]
The emoji of the button, if any.
sku_id: Optional[Union[:class:`int`]]
The ID of the SKU this button refers to.
id: Optional[:class:`int`]
The button's ID.
"""
button = Button(
style=style,
label=label,
disabled=disabled,
custom_id=custom_id,
url=url,
emoji=emoji,
sku_id=sku_id,
id=id,
)
return self.add_item(button)
@overload
def add_select(
self,
select_type: Literal[ComponentType.string_select] = ...,
*,
custom_id: str | None = ...,
placeholder: str | None = ...,
min_values: int = ...,
max_values: int = ...,
options: list[SelectOption] | None = ...,
disabled: bool = ...,
id: int | None = ...,
) -> None: ...
@overload
def add_select(
self,
select_type: Literal[ComponentType.channel_select] = ...,
*,
custom_id: str | None = ...,
placeholder: str | None = ...,
min_values: int = ...,
max_values: int = ...,
channel_types: list[ChannelType] | None = ...,
disabled: bool = ...,
id: int | None = ...,
default_values: Sequence[SelectDefaultValue] | None = ...,
) -> None: ...
@overload
def add_select(
self,
select_type: Literal[
ComponentType.user_select,
ComponentType.role_select,
ComponentType.mentionable_select,
] = ...,
*,
custom_id: str | None = ...,
placeholder: str | None = ...,
min_values: int = ...,
max_values: int = ...,
disabled: bool = ...,
id: int | None = ...,
default_values: Sequence[SelectDefaultValue] | None = ...,
) -> None: ...
def add_select(
self,
select_type: ComponentType = ComponentType.string_select,
*,
custom_id: str | None = None,
placeholder: str | None = None,
min_values: int = 1,
max_values: int = 1,
options: list[SelectOption] | None = None,
channel_types: list[ChannelType] | None = None,
disabled: bool = False,
id: int | None = None,
default_values: Sequence[SelectDefaultValue] | None = None,
) -> Self:
"""Adds a :class:`Select` to the action row.
To append a pre-existing :class:`Select`, use the
:meth:`add_item` method instead.
Parameters
----------
select_type: :class:`discord.ComponentType`
The type of select to create. Must be one of
:attr:`discord.ComponentType.string_select`, :attr:`discord.ComponentType.user_select`,
:attr:`discord.ComponentType.role_select`, :attr:`discord.ComponentType.mentionable_select`,
or :attr:`discord.ComponentType.channel_select`.
custom_id: :class:`str`
The custom ID of the select menu that gets received during an interaction.
If not given then one is generated for you.
placeholder: Optional[:class:`str`]
The placeholder text that is shown if nothing is selected, if any.
min_values: :class:`int`
The minimum number of items that must be chosen for this select menu.
Defaults to 1 and must be between 1 and 25.
max_values: :class:`int`
The maximum number of items that must be chosen for this select menu.
Defaults to 1 and must be between 1 and 25.
options: List[:class:`discord.SelectOption`]
A list of options that can be selected in this menu.
Only valid for selects of type :attr:`discord.ComponentType.string_select`.
channel_types: List[:class:`discord.ChannelType`]
A list of channel types that can be selected in this menu.
Only valid for selects of type :attr:`discord.ComponentType.channel_select`.
disabled: :class:`bool`
Whether the select is disabled or not. Defaults to ``False``.
id: Optional[:class:`int`]
The select menu's ID.
default_values: Optional[Sequence[Union[:class:`discord.SelectDefaultValue`, :class:`discord.abc.Snowflake`]]]
The default values of this select. Only applicable if :attr:`.select_type` is not :attr:`discord.ComponentType.string_select`.
These can be either :class:`discord.SelectDefaultValue` instances or models, which will be converted into :class:`discord.SelectDefaultValue`
instances.
"""
select = Select(
select_type=select_type,
custom_id=custom_id,
placeholder=placeholder,
min_values=min_values,
max_values=max_values,
options=options or [],
channel_types=channel_types or [],
disabled=disabled,
id=id,
default_values=default_values,
)
return self.add_item(select)
def is_dispatchable(self) -> bool:
return any(item.is_dispatchable() for item in self.children)
def is_persistent(self) -> bool:
return all(item.is_persistent() for item in self.children)
def refresh_component(self, component: ActionRowComponent) -> None:
self.underlying = component
for i, y in enumerate(component.components):
x = self.children[i]
x.refresh_component(y)
def disable_all_items(self, *, exclusions: list[ViewItem] | None = None) -> Self:
"""
Disables all items in the row.
Parameters
----------
exclusions: Optional[List[:class:`ViewItem`]]
A list of items in `self.children` to not disable.
"""
for item in self.walk_items():
if exclusions is None or item not in exclusions:
item.disabled = True
return self
def enable_all_items(self, *, exclusions: list[ViewItem] | None = None) -> Self:
"""
Enables all items in the row.
Parameters
----------
exclusions: Optional[List[:class:`ViewItem`]]
A list of items in `self.children` to not enable.
"""
for item in self.walk_items():
if exclusions is None or item not in exclusions:
item.disabled = False
return self
@property
def width(self):
"""Return the sum of the items' widths."""
t = 0
for item in self.children:
t += 1 if item.underlying.type is ComponentType.button else 5
return t
def walk_items(self) -> Iterator[ViewItem]:
yield from self.children
def to_component_dict(self) -> ActionRowPayload:
self._underlying = self._generate_underlying()
return super().to_component_dict()
@classmethod
def from_component(cls: type[A], component: ActionRowComponent) -> A:
from .view import _component_to_item, _walk_all_components
items = [
_component_to_item(c) for c in _walk_all_components(component.components)
]
return cls(
*items,
id=component.id,
)
callback = None
@@ -0,0 +1,419 @@
"""
The MIT License (MIT)
Copyright (c) 2015-2021 Rapptz
Copyright (c) 2021-present Pycord Development
Permission is hereby granted, free of charge, to any person obtaining a
copy of this software and associated documentation files (the "Software"),
to deal in the Software without restriction, including without limitation
the rights to use, copy, modify, merge, publish, distribute, sublicense,
and/or sell copies of the Software, and to permit persons to whom the
Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
DEALINGS IN THE SOFTWARE.
"""
from __future__ import annotations
import inspect
import os
from typing import TYPE_CHECKING, Callable, TypeVar
from ..components import Button as ButtonComponent
from ..enums import ButtonStyle, ComponentType
from ..partial_emoji import PartialEmoji, _EmojiTag
from .item import ItemCallbackType, ViewItem
__all__ = (
"Button",
"button",
)
if TYPE_CHECKING:
from ..emoji import AppEmoji, GuildEmoji
from ..types.components import ButtonComponent as ButtonComponentPayload
from .view import BaseView
B = TypeVar("B", bound="Button")
V = TypeVar("V", bound="BaseView", covariant=True)
class Button(ViewItem[V]):
"""Represents a UI button.
.. versionadded:: 2.0
Parameters
----------
style: :class:`discord.ButtonStyle`
The style of the button.
custom_id: Optional[:class:`str`]
The ID of the button that gets received during an interaction.
If this button is for a URL, it does not have a custom ID.
url: Optional[:class:`str`]
The URL this button sends you to.
disabled: :class:`bool`
Whether the button is disabled or not.
label: Optional[:class:`str`]
The label of the button, if any. Maximum of 80 chars.
emoji: Optional[Union[:class:`.PartialEmoji`, :class:`GuildEmoji`, :class:`AppEmoji`, :class:`str`]]
The emoji of the button, if available.
sku_id: Optional[Union[:class:`int`]]
The ID of the SKU this button refers to.
row: Optional[:class:`int`]
The relative row this button belongs to. A Discord component can only have 5
rows. By default, items are arranged automatically into those 5 rows. If you'd
like to control the relative positioning of the row then passing an index is advised.
For example, row=1 will show up before row=2. Defaults to ``None``, which is automatic
ordering. The row number must be between 0 and 4 (i.e. zero indexed).
.. warning::
This parameter does not work in :class:`ActionRow`.
id: Optional[:class:`int`]
The button's ID.
"""
__item_repr_attributes__: tuple[str, ...] = (
"style",
"url",
"disabled",
"label",
"emoji",
"sku_id",
"row",
"custom_id",
"id",
)
def __init__(
self,
*,
style: ButtonStyle = ButtonStyle.secondary,
label: str | None = None,
disabled: bool = False,
custom_id: str | None = None,
url: str | None = None,
emoji: str | GuildEmoji | AppEmoji | PartialEmoji | None = None,
sku_id: int | None = None,
row: int | None = None,
id: int | None = None,
):
self._row: int | None = None
self._rendered_row: int | None = None
super().__init__()
if label and len(str(label)) > 80:
raise ValueError("label must be 80 characters or fewer")
if custom_id is not None and len(str(custom_id)) > 100:
raise ValueError("custom_id must be 100 characters or fewer")
if custom_id is not None and url is not None:
raise TypeError("cannot mix both url and custom_id with Button")
if sku_id is not None and url is not None:
raise TypeError("cannot mix both url and sku_id with Button")
if custom_id is not None and sku_id is not None:
raise TypeError("cannot mix both sku_id and custom_id with Button")
if not isinstance(custom_id, str) and custom_id is not None:
raise TypeError(
f"expected custom_id to be str, not {custom_id.__class__.__name__}"
)
self._provided_custom_id = custom_id is not None
if url is None and custom_id is None and sku_id is None:
custom_id = os.urandom(16).hex()
if url is not None:
style = ButtonStyle.link
if sku_id is not None:
style = ButtonStyle.premium
if emoji is not None:
if isinstance(emoji, str):
emoji = PartialEmoji.from_str(emoji)
elif isinstance(emoji, _EmojiTag):
emoji = emoji._to_partial()
else:
raise TypeError(
"expected emoji to be str, GuildEmoji, AppEmoji, or PartialEmoji not"
f" {emoji.__class__}"
)
self.row = row
self._underlying = self._generate_underlying(
custom_id=custom_id,
url=url,
disabled=disabled,
label=label,
style=style,
emoji=emoji,
sku_id=sku_id,
id=id,
)
def _generate_underlying(
self,
style: ButtonStyle | None = None,
label: str | None = None,
disabled: bool | None = None,
custom_id: str | None = None,
url: str | None = None,
emoji: str | GuildEmoji | AppEmoji | PartialEmoji | None = None,
sku_id: int | None = None,
id: int | None = None,
) -> ButtonComponent:
super()._generate_underlying(ButtonComponent)
return ButtonComponent._raw_construct(
type=ComponentType.button,
custom_id=custom_id or self.custom_id,
url=url or self.url,
disabled=disabled if disabled is not None else self.disabled,
label=label or self.label,
style=style or self.style,
emoji=emoji or self.emoji,
sku_id=sku_id or self.sku_id,
id=id or self.id,
)
@property
def style(self) -> ButtonStyle:
"""The style of the button."""
return self.underlying.style
@style.setter
def style(self, value: ButtonStyle):
self.underlying.style = value
@property
def custom_id(self) -> str | None:
"""The ID of the button that gets received during an interaction.
If this button is for a URL, it does not have a custom ID.
"""
return self.underlying.custom_id
@custom_id.setter
def custom_id(self, value: str | None):
if value is not None and not isinstance(value, str):
raise TypeError("custom_id must be None or str")
if value and len(value) > 100:
raise ValueError("custom_id must be 100 characters or fewer")
self.underlying.custom_id = value
self._provided_custom_id = value is not None
@property
def url(self) -> str | None:
"""The URL this button sends you to."""
return self.underlying.url
@url.setter
def url(self, value: str | None):
if value is not None and not isinstance(value, str):
raise TypeError("url must be None or str")
self.underlying.url = value
@property
def disabled(self) -> bool:
"""Whether the button is disabled or not."""
return self.underlying.disabled
@disabled.setter
def disabled(self, value: bool):
self.underlying.disabled = bool(value)
@property
def label(self) -> str | None:
"""The label of the button, if available."""
return self.underlying.label
@label.setter
def label(self, value: str | None):
if value and len(str(value)) > 80:
raise ValueError("label must be 80 characters or fewer")
self.underlying.label = str(value) if value is not None else value
@property
def emoji(self) -> PartialEmoji | None:
"""The emoji of the button, if available."""
return self.underlying.emoji
@emoji.setter
def emoji(self, value: str | GuildEmoji | AppEmoji | PartialEmoji | None): # type: ignore
if value is None:
self.underlying.emoji = None
elif isinstance(value, str):
self.underlying.emoji = PartialEmoji.from_str(value)
elif isinstance(value, _EmojiTag):
self.underlying.emoji = value._to_partial()
else:
raise TypeError(
"expected str, GuildEmoji, AppEmoji, or PartialEmoji, received"
f" {value.__class__} instead"
)
@property
def sku_id(self) -> int | None:
"""The ID of the SKU this button refers to."""
return self.underlying.sku_id
@sku_id.setter
def sku_id(self, value: int | None): # type: ignore
if value is None:
self.underlying.sku_id = None
elif isinstance(value, int):
self.underlying.sku_id = value
else:
raise TypeError(f"expected int or None, received {value.__class__} instead")
@property
def width(self) -> int:
"""Gets the width of the item in the UI layout.
The width determines how much horizontal space this item occupies within its row.
Returns
-------
:class:`int`
The width of the item. Buttons have a width of 1.
"""
return 1
@property
def row(self) -> int | None:
"""Gets or sets the row position of this item within its parent view.
The row position determines the vertical placement of the item in the UI.
The value must be an integer between 0 and 4 (inclusive), or ``None`` to indicate
that no specific row is set.
This attribute is not compatible with :class:`discord.ui.DesignerView`.
Returns
-------
Optional[:class:`int`]
The row position of the item, or ``None`` if not explicitly set.
Raises
------
ValueError
If the row value is not ``None`` and is outside the range [0, 4].
"""
return self._row
@row.setter
def row(self, value: int | None):
if value is None or 5 > value >= 0:
self._row = value
else:
raise ValueError("row cannot be negative or greater than or equal to 5")
@classmethod
def from_component(cls: type[B], button: ButtonComponent) -> B:
return cls(
style=button.style,
label=button.label,
disabled=button.disabled,
custom_id=button.custom_id,
url=button.url,
emoji=button.emoji,
sku_id=button.sku_id,
row=None,
id=button.id,
)
def to_component_dict(self) -> ButtonComponentPayload:
return super().to_component_dict()
def is_dispatchable(self) -> bool:
return (self.custom_id is not None) and (
bool(self.view._store) if self.view else True
)
def is_storable(self) -> bool:
return self.is_dispatchable()
def is_persistent(self) -> bool:
if self.style is ButtonStyle.link:
return self.url is not None
return super().is_persistent()
def refresh_component(self, button: ButtonComponent) -> None:
self.underlying = button
def button(
*,
label: str | None = None,
custom_id: str | None = None,
disabled: bool = False,
style: ButtonStyle = ButtonStyle.secondary,
emoji: str | GuildEmoji | AppEmoji | PartialEmoji | None = None,
row: int | None = None,
id: int | None = None,
) -> Callable[[ItemCallbackType[Button[V]]], Button[V]]:
"""A decorator that attaches a button to a component.
The function being decorated should have three parameters, ``self`` representing
the :class:`discord.ui.View`, :class:`discord.ui.ActionRow` or :class:`discord.ui.Section`, the :class:`discord.ui.Button` being pressed, and
the :class:`discord.Interaction` you receive.
.. note::
Premium and link buttons cannot be created with this decorator. Consider
creating a :class:`Button` object manually instead. These types of
buttons do not have a callback associated since Discord doesn't handle
them when clicked.
Parameters
----------
label: Optional[:class:`str`]
The label of the button, if any.
custom_id: Optional[:class:`str`]
The ID of the button that gets received during an interaction.
It is recommended not to set this parameter to prevent conflicts.
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:`GuildEmoji`, :class:`AppEmoji`, :class:`.PartialEmoji`]]
The emoji of the button. This can be in string form or a :class:`.PartialEmoji`
or a full :class:`GuildEmoji` or :class:`AppEmoji`.
row: Optional[:class:`int`]
The relative row this button belongs to. A Discord component can only have 5
rows. By default, items are arranged automatically into those 5 rows. If you'd
like to control the relative positioning of the row then passing an index is advised.
For example, row=1 will show up before row=2. Defaults to ``None``, which is automatic
ordering. The row number must be between 0 and 4 (i.e. zero indexed).
.. warning::
This parameter does not work in :class:`ActionRow`.
"""
def decorator(func: ItemCallbackType) -> ItemCallbackType:
if not inspect.iscoroutinefunction(func):
raise TypeError("button function must be a coroutine function")
func.__discord_ui_model_type__ = Button
func.__discord_ui_model_kwargs__ = {
"style": style,
"custom_id": custom_id,
"url": None,
"disabled": disabled,
"label": label,
"emoji": emoji,
"row": row,
"id": id,
}
return func
return decorator # type: ignore # lie to the type checkers, because after a View is instated, the button callback is converted into a Button instance
@@ -0,0 +1,137 @@
"""
The MIT License (MIT)
Copyright (c) 2021-present Pycord Development
Permission is hereby granted, free of charge, to any person obtaining a
copy of this software and associated documentation files (the "Software"),
to deal in the Software without restriction, including without limitation
the rights to use, copy, modify, merge, publish, distribute, sublicense,
and/or sell copies of the Software, and to permit persons to whom the
Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
DEALINGS IN THE SOFTWARE.
"""
from __future__ import annotations
import os
from typing import TYPE_CHECKING
from ..components import Checkbox as CheckboxComponent
from ..enums import ComponentType
from .item import ModalItem
__all__ = ("Checkbox",)
if TYPE_CHECKING:
from ..interactions import Interaction
from ..types.components import CheckboxComponent as CheckboxComponentPayload
class Checkbox(ModalItem):
"""Represents a UI Checkbox component.
.. versionadded:: 2.8
Parameters
----------
custom_id: Optional[:class:`str`]
The ID of the checkbox that gets received during an interaction.
default: Optional[:class:`bool`]
Whether this checkbox is selected by default or not.
id: Optional[:class:`int`]
The checkbox's ID.
"""
__item_repr_attributes__: tuple[str, ...] = (
"default",
"custom_id",
"id",
)
def __init__(
self,
*,
custom_id: str | None = None,
default: bool = False,
id: int | None = None,
):
super().__init__()
if custom_id is not None and not isinstance(custom_id, str):
raise TypeError(
f"expected custom_id to be str, not {custom_id.__class__.__name__}"
)
if not isinstance(default, bool):
raise TypeError(f"default must be bool, not {default.__class__.__name__}")
custom_id = os.urandom(16).hex() if custom_id is None else custom_id
self._value: bool | None = None
self._underlying: CheckboxComponent = self._generate_underlying(
custom_id=custom_id,
default=default,
id=id,
)
def _generate_underlying(
self,
custom_id: str | None = None,
default: bool | None = None,
id: int | None = None,
) -> CheckboxComponent:
super()._generate_underlying(CheckboxComponent)
return CheckboxComponent._raw_construct(
type=ComponentType.checkbox,
custom_id=custom_id or self.custom_id,
default=default if default is not None else self.default,
id=id or self.id,
)
@property
def custom_id(self) -> str:
"""The custom id that gets received during an interaction."""
return self.underlying.custom_id
@custom_id.setter
def custom_id(self, value: str):
if not isinstance(value, str):
raise TypeError(f"custom_id must be str not {value.__class__.__name__}")
if len(value) > 100:
raise ValueError("custom_id must be 100 characters or fewer")
self.underlying.custom_id = value
@property
def default(self) -> bool:
"""Whether this checkbox is selected by default or not. Defaults to ``False``"""
return self.underlying.default
@default.setter
def default(self, value: bool):
if not isinstance(value, bool):
raise TypeError(f"default must be bool, not {value.__class__.__name__}")
self.underlying.default = bool(value)
@property
def value(self) -> bool | None:
"""Whether this checkbox was selected or not by the user. This will be ``None`` if the checkbox has not been submitted via a modal yet."""
return self._value
def to_component_dict(self) -> CheckboxComponentPayload:
return self.underlying.to_dict()
def refresh_state(self, data) -> None:
self._value = data.get("value", None)
def refresh_from_modal(
self, interaction: Interaction, data: CheckboxComponentPayload
) -> None:
return self.refresh_state(data)
@@ -0,0 +1,286 @@
"""
The MIT License (MIT)
Copyright (c) 2021-present Pycord Development
Permission is hereby granted, free of charge, to any person obtaining a
copy of this software and associated documentation files (the "Software"),
to deal in the Software without restriction, including without limitation
the rights to use, copy, modify, merge, publish, distribute, sublicense,
and/or sell copies of the Software, and to permit persons to whom the
Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
DEALINGS IN THE SOFTWARE.
"""
from __future__ import annotations
import os
from typing import TYPE_CHECKING
from typing_extensions import Self
from ..components import CheckboxGroup as CheckboxGroupComponent
from ..components import CheckboxGroupOption
from ..enums import ComponentType
from ..utils import MISSING
from .item import ModalItem
__all__ = ("CheckboxGroup",)
if TYPE_CHECKING:
from ..interactions import Interaction
from ..types.components import (
CheckboxGroupComponent as CheckboxGroupComponentPayload,
)
class CheckboxGroup(ModalItem):
"""Represents a UI Checkbox Group component.
.. versionadded:: 2.8
Parameters
----------
custom_id: Optional[:class:`str`]
The ID of the checkbox group that gets received during an interaction.
options: List[:class:`discord.CheckboxGroupOption`]
A list of options that can be selected in this group.
min_values: Optional[:class:`int`]
The minimum number of options that must be selected.
Defaults to 0 and must be between 0 and 10, inclusive.
max_values: Optional[:class:`int`]
The maximum number of options that can be selected.
Must be between 1 and 10, inclusive.
required: Optional[:class:`bool`]
Whether an option selection is required or not. Defaults to ``True``.
id: Optional[:class:`int`]
The checkbox group's ID.
"""
__item_repr_attributes__: tuple[str, ...] = (
"options",
"required",
"min_values",
"max_values",
"custom_id",
"id",
)
def __init__(
self,
*,
custom_id: str | None = None,
options: list[CheckboxGroupOption] | None = None,
min_values: int | None = None,
max_values: int | None = None,
required: bool = True,
id: int | None = None,
):
super().__init__()
if min_values and (min_values < 0 or min_values > 10):
raise ValueError("min_values must be between 0 and 10")
if max_values and (max_values < 1 or max_values > 10):
raise ValueError("max_values must be between 1 and 10")
if custom_id is not None and not isinstance(custom_id, str):
raise TypeError(
f"expected custom_id to be str, not {custom_id.__class__.__name__}"
)
if not isinstance(required, bool):
raise TypeError(f"required must be bool not {required.__class__.__name__}")
custom_id = os.urandom(16).hex() if custom_id is None else custom_id
self._selected_values: list[str] | None = None
self._underlying: CheckboxGroupComponent = self._generate_underlying(
custom_id=custom_id,
options=[],
min_values=min_values,
max_values=max_values,
required=required,
id=id,
)
if options:
self.options = options
def _generate_underlying(
self,
custom_id: str | None = None,
options: list[CheckboxGroupOption] | None = None,
min_values: int | None = None,
max_values: int | None = None,
required: bool | None = None,
id: int | None = None,
) -> CheckboxGroupComponent:
super()._generate_underlying(CheckboxGroupComponent)
return CheckboxGroupComponent._raw_construct(
type=ComponentType.checkbox_group,
custom_id=custom_id or self.custom_id,
options=options if options is not None else self.options,
min_values=min_values if min_values is not None else self.min_values,
max_values=max_values if max_values is not None else self.max_values,
required=required if required is not None else self.required,
id=id or self.id,
)
@property
def custom_id(self) -> str:
"""The custom id that gets received during an interaction."""
return self.underlying.custom_id
@custom_id.setter
def custom_id(self, value: str):
if not isinstance(value, str):
raise TypeError(f"custom_id must be str not {value.__class__.__name__}")
if len(value) > 100:
raise ValueError("custom_id must be 100 characters or fewer")
self.underlying.custom_id = value
@property
def min_values(self) -> int | None:
"""The minimum number of options that must be selected."""
return self.underlying.min_values
@min_values.setter
def min_values(self, value: int | None):
if value and not isinstance(value, int):
raise TypeError(
f"min_values must be None or int not {value.__class__.__name__}"
)
if value and (value < 0 or value > 10):
raise ValueError("min_values must be between 0 and 10")
self.underlying.min_values = value
@property
def max_values(self) -> int | None:
"""The maximum number of options that can be selected."""
return self.underlying.max_values
@max_values.setter
def max_values(self, value: int | None):
if value and not isinstance(value, int):
raise TypeError(
f"max_values must be None or int not {value.__class__.__name__}"
)
if value and (value < 1 or value > 10):
raise ValueError("max_values must be between 1 and 10")
self.underlying.max_values = value
@property
def required(self) -> bool:
"""Whether an option selection is required or not. Defaults to ``True``"""
return self.underlying.required
@required.setter
def required(self, value: bool):
if not isinstance(value, bool):
raise TypeError(f"required must be bool, not {value.__class__.__name__}")
self.underlying.required = bool(value)
@property
def values(self) -> list[str] | None:
"""The values selected by the user. This will be ``None`` if the checkbox group has not been submitted in a modal yet."""
return self._selected_values
@property
def options(self) -> list[CheckboxGroupOption]:
"""A list of options that can be selected in this group."""
return self.underlying.options
@options.setter
def options(self, value: list[CheckboxGroupOption]):
if not isinstance(value, list):
raise TypeError("options must be a list of CheckboxGroupOption")
if len(value) > 10:
raise ValueError("you may only provide up to 10 options.")
if not all(isinstance(obj, CheckboxGroupOption) for obj in value):
raise TypeError("all list items must subclass CheckboxGroupOption")
self.underlying.options = value
def add_option(
self,
*,
label: str,
value: str = MISSING,
description: str | None = None,
default: bool = False,
) -> Self:
"""Adds an option to the checkbox group.
To append a pre-existing :class:`discord.CheckboxGroupOption` use the
:meth:`append_option` method instead.
Parameters
----------
label: :class:`str`
The label of the option. This is displayed to users.
Can only be up to 100 characters.
value: :class:`str`
The value of the option. This is not displayed to users.
If not given, defaults to the label. Can only be up to 100 characters.
description: Optional[:class:`str`]
An additional description of the option, if any.
Can only be up to 100 characters.
default: :class:`bool`
Whether this option is selected by default.
Raises
------
ValueError
The number of options exceeds 10.
"""
option = CheckboxGroupOption(
label=label,
value=value,
description=description,
default=default,
)
return self.append_option(option)
def append_option(self, option: CheckboxGroupOption) -> Self:
"""Appends an option to the checkbox group.
Parameters
----------
option: :class:`discord.CheckboxGroupOption`
The option to append to the checkbox group.
Raises
------
ValueError
The number of options exceeds 10.
"""
if len(self.underlying.options) >= 10:
raise ValueError("maximum number of options already provided")
self.underlying.options.append(option)
return self
def clear_options(self) -> Self:
"""Remove all options from the checkbox group."""
self.underlying.options.clear()
return self
def to_component_dict(self) -> CheckboxGroupComponentPayload:
return self.underlying.to_dict()
def refresh_state(self, data) -> None:
self._selected_values = data.get("values", [])
def refresh_from_modal(
self, interaction: Interaction, data: CheckboxGroupComponentPayload
) -> None:
return self.refresh_state(data)
@@ -0,0 +1,442 @@
"""
The MIT License (MIT)
Copyright (c) 2021-present Pycord Development
Permission is hereby granted, free of charge, to any person obtaining a
copy of this software and associated documentation files (the "Software"),
to deal in the Software without restriction, including without limitation
the rights to use, copy, modify, merge, publish, distribute, sublicense,
and/or sell copies of the Software, and to permit persons to whom the
Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
DEALINGS IN THE SOFTWARE.
"""
from __future__ import annotations
from typing import TYPE_CHECKING, Iterator, TypeVar
from ..colour import Colour
from ..components import Container as ContainerComponent
from ..components import MediaGalleryItem, _component_factory
from ..enums import ComponentType, SeparatorSpacingSize
from ..utils import find, get
from .action_row import ActionRow
from .button import Button
from .file import File
from .item import ItemCallbackType, ViewItem
from .media_gallery import MediaGallery
from .section import Section
from .select import Select
from .separator import Separator
from .text_display import TextDisplay
__all__ = ("Container",)
if TYPE_CHECKING:
from typing_extensions import Self
from ..types.components import ContainerComponent as ContainerComponentPayload
from .view import DesignerView
C = TypeVar("C", bound="Container")
V = TypeVar("V", bound="DesignerView", covariant=True)
class Container(ViewItem[V]):
"""Represents a UI Container.
The current items supported are as follows:
- :class:`discord.ui.ActionRow`
- :class:`discord.ui.Section`
- :class:`discord.ui.TextDisplay`
- :class:`discord.ui.MediaGallery`
- :class:`discord.ui.File`
- :class:`discord.ui.Separator`
.. versionadded:: 2.7
Parameters
----------
*items: :class:`ViewItem`
The initial items in this container.
colour: Union[:class:`Colour`, :class:`int`]
The accent colour of the container. Aliased to ``color`` as well.
spoiler: Optional[:class:`bool`]
Whether this container has the spoiler overlay.
id: Optional[:class:`int`]
The container's ID.
Attributes
----------
items: List[:class:`ViewItem`]
The list of items in this container.
"""
__item_repr_attributes__: tuple[str, ...] = (
"items",
"colour",
"spoiler",
"id",
)
def __init_subclass__(cls) -> None:
for base in reversed(cls.__mro__):
for member in base.__dict__.values():
if hasattr(member, "__discord_ui_model_type__"):
raise ValueError(
"The @button and @select decorators are incompatible with Container. Use ActionRow instead."
)
def __init__(
self,
*items: ViewItem,
colour: int | Colour | None = None,
color: int | Colour | None = None,
spoiler: bool = False,
id: int | None = None,
):
super().__init__()
self.items: list[ViewItem] = []
self._underlying = self._generate_underlying(
id=id,
accent_color=colour or color,
spoiler=spoiler,
)
for i in items:
self.add_item(i)
def _add_component_from_item(self, item: ViewItem):
self.underlying.components.append(item._generate_underlying())
def _set_components(self, items: list[ViewItem]):
self.underlying.components.clear()
for item in items:
self._add_component_from_item(item)
def _generate_underlying(
self,
accent_color: int | Colour | None = None,
spoiler: bool | None = None,
id: int | None = None,
) -> ContainerComponent:
super()._generate_underlying(ContainerComponent)
container = ContainerComponent._raw_construct(
type=ComponentType.container,
id=id or self.id,
components=[],
accent_color=Colour.resolve_value(accent_color or self.colour),
spoiler=spoiler if spoiler is not None else self.spoiler,
)
for i in self.items:
container.components.append(i._generate_underlying())
return container
def add_item(self, item: ViewItem) -> Self:
"""Adds an item to the container.
Parameters
----------
item: :class:`ViewItem`
The item to add to the container.
Raises
------
TypeError
A :class:`ViewItem` was not passed.
"""
if not isinstance(item, ViewItem):
raise TypeError(f"expected ViewItem not {item.__class__!r}")
if isinstance(item, (Button, Select)):
raise TypeError(
f"{item.__class__!r} cannot be added directly. Use ActionRow instead."
)
item.parent = self
self.items.append(item)
self._add_component_from_item(item)
return self
def remove_item(self, item: ViewItem | str | int) -> Self:
"""Removes an item from the container. If an int or str is passed, it will remove by Item :attr:`id` or ``custom_id`` respectively.
Parameters
----------
item: Union[:class:`ViewItem`, :class:`int`, :class:`str`]
The item, ``id``, or item ``custom_id`` to remove from the container.
"""
if isinstance(item, (str, int)):
item = self.get_item(item)
try:
if item.parent is self:
self.items.remove(item)
item.parent = None
else:
item.parent.remove_item(item)
except ValueError:
pass
return self
def get_item(self, id: str | int) -> ViewItem | None:
"""Get an item from this container. Roughly equivalent to `utils.get(container.items, ...)`.
If an ``int`` is provided, the item will be retrieved by ``id``, otherwise by ``custom_id``.
This method will also search for nested items.
Parameters
----------
id: Union[:class:`str`, :class:`int`]
The id or custom_id of the item to get.
Returns
-------
Optional[:class:`ViewItem`]
The item with the matching ``id`` or ``custom_id`` if it exists.
"""
if not id:
return None
attr = "id" if isinstance(id, int) else "custom_id"
child = find(lambda i: getattr(i, attr, None) == id, self.items)
if not child:
for i in self.items:
if hasattr(i, "get_item"):
if child := i.get_item(id):
return child
return child
def add_row(
self,
*items: ViewItem,
id: int | None = None,
) -> Self:
"""Adds an :class:`ActionRow` to the container.
To append a pre-existing :class:`ActionRow`, use :meth:`add_item` instead.
Parameters
----------
*items: Union[:class:`Button`, :class:`Select`]
The items this action row contains.
id: Optiona[:class:`int`]
The action row's ID.
"""
row = ActionRow(*items, id=id)
return self.add_item(row)
def add_section(
self,
*items: ViewItem,
accessory: ViewItem,
id: int | None = None,
) -> Self:
"""Adds a :class:`Section` to the container.
To append a pre-existing :class:`Section`, use the
:meth:`add_item` method, instead.
Parameters
----------
*items: :class:`ViewItem`
The items contained in this section, up to 3.
Currently only supports :class:`~discord.ui.TextDisplay`.
accessory: Optional[:class:`ViewItem`]
The section's accessory. This is displayed in the top right of the section.
Currently only supports :class:`~discord.ui.Button` and :class:`~discord.ui.Thumbnail`.
id: Optional[:class:`int`]
The section's ID.
"""
section = Section(*items, accessory=accessory, id=id)
return self.add_item(section)
def add_text(self, content: str, id: int | None = None) -> Self:
"""Adds a :class:`TextDisplay` to the container.
Parameters
----------
content: :class:`str`
The content of the TextDisplay
id: Optiona[:class:`int`]
The text displays' ID.
"""
text = TextDisplay(content, id=id)
return self.add_item(text)
def add_gallery(
self,
*items: MediaGalleryItem,
id: int | None = None,
) -> Self:
"""Adds a :class:`MediaGallery` to the container.
To append a pre-existing :class:`MediaGallery`, use :meth:`add_item` instead.
Parameters
----------
*items: :class:`MediaGalleryItem`
The media this gallery contains.
id: Optiona[:class:`int`]
The gallery's ID.
"""
g = MediaGallery(*items, id=id)
return self.add_item(g)
def add_file(self, url: str, spoiler: bool = False, id: int | None = None) -> Self:
"""Adds a :class:`File` to the container.
Parameters
----------
url: :class:`str`
The URL of this file's media. This must be an ``attachment://`` URL that references a :class:`~discord.File`.
spoiler: Optional[:class:`bool`]
Whether the file has the spoiler overlay. Defaults to ``False``.
id: Optiona[:class:`int`]
The file's ID.
"""
f = File(url, spoiler=spoiler, id=id)
return self.add_item(f)
def add_separator(
self,
*,
divider: bool = True,
spacing: SeparatorSpacingSize = SeparatorSpacingSize.small,
id: int | None = None,
) -> Self:
"""Adds a :class:`Separator` to the container.
Parameters
----------
divider: :class:`bool`
Whether the separator is a divider. Defaults to ``True``.
spacing: :class:`~discord.SeparatorSpacingSize`
The spacing size of the separator. Defaults to :attr:`~discord.SeparatorSpacingSize.small`.
id: Optional[:class:`int`]
The separator's ID.
"""
s = Separator(divider=divider, spacing=spacing, id=id)
return self.add_item(s)
def copy_text(self) -> str:
"""Returns the text of all :class:`~discord.ui.TextDisplay` items in this container.
Equivalent to the `Copy Text` option on Discord clients.
"""
return "\n".join(t for i in self.items if (t := i.copy_text()))
@property
def spoiler(self) -> bool:
"""Whether the container has the spoiler overlay. Defaults to ``False``."""
return self.underlying.spoiler
@spoiler.setter
def spoiler(self, spoiler: bool) -> None:
self.underlying.spoiler = spoiler
@property
def colour(self) -> Colour | None:
return self.underlying.accent_color
@colour.setter
def colour(self, value: int | Colour | None): # type: ignore
self.underlying.accent_color = Colour.resolve_value(value)
color = colour
def is_dispatchable(self) -> bool:
return any(item.is_dispatchable() for item in self.items)
def is_persistent(self) -> bool:
return all(item.is_persistent() for item in self.items)
def refresh_component(self, component: ContainerComponent) -> None:
self.underlying = component
i = 0
for y in component.components:
x = self.items[i]
x.refresh_component(y)
i += 1
def disable_all_items(self, *, exclusions: list[ViewItem] | None = None) -> Self:
"""
Disables all buttons and select menus in the container.
Parameters
----------
exclusions: Optional[List[:class:`ViewItem`]]
A list of items in `self.items` to not disable from the view.
"""
for item in self.walk_items():
if hasattr(item, "disabled") and (
exclusions is None or item not in exclusions
):
item.disabled = True
return self
def enable_all_items(self, *, exclusions: list[ViewItem] | None = None) -> Self:
"""
Enables all buttons and select menus in the container.
Parameters
----------
exclusions: Optional[List[:class:`ViewItem`]]
A list of items in `self.items` to not enable from the view.
"""
for item in self.walk_items():
if hasattr(item, "disabled") and (
exclusions is None or item not in exclusions
):
item.disabled = False
return self
def walk_items(self) -> Iterator[ViewItem]:
for item in self.items:
if hasattr(item, "walk_items"):
yield from item.walk_items()
else:
yield item
def to_component_dict(self) -> ContainerComponentPayload:
self._underlying = self._generate_underlying()
return super().to_component_dict()
@classmethod
def from_component(cls: type[C], component: ContainerComponent) -> C:
from .view import _component_to_item
items = [_component_to_item(c) for c in component.components]
return cls(
*items,
colour=component.accent_color,
spoiler=component.spoiler,
id=component.id,
)
callback = None
@@ -0,0 +1,157 @@
"""
The MIT License (MIT)
Copyright (c) 2021-present Pycord Development
Permission is hereby granted, free of charge, to any person obtaining a
copy of this software and associated documentation files (the "Software"),
to deal in the Software without restriction, including without limitation
the rights to use, copy, modify, merge, publish, distribute, sublicense,
and/or sell copies of the Software, and to permit persons to whom the
Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
DEALINGS IN THE SOFTWARE.
"""
from __future__ import annotations
import asyncio
import time
from itertools import groupby
from typing import TYPE_CHECKING, Any, Callable
from ..utils import find, get
from .item import Item, ItemCallbackType
__all__ = ("ItemInterface",)
if TYPE_CHECKING:
from typing_extensions import Self
from .view import View
class ItemInterface:
"""The base structure for classes that contain :class:`~discord.ui.Item`.
.. versionadded:: 2.7
Parameters
----------
*items: :class:`Item`
The initial items contained in this structure.
timeout: Optional[:class:`float`]
Timeout in seconds from last interaction with the UI before no longer accepting input. Defaults to 180.0.
If ``None`` then there is no timeout.
Attributes
----------
timeout: Optional[:class:`float`]
Timeout from last interaction with the UI before no longer accepting input.
If ``None`` then there is no timeout.
children: List[:class:`Item`]
The list of children attached to this structure.
store: Optional[:class:`bool`]
Whether this interface should be stored for callback listening. Setting it to ``False`` will ignore callbacks and prevent item values from being refreshed. Defaults to ``True``.
"""
def __init__(
self,
*items: Item,
timeout: float | None = 180.0,
store: bool = True,
):
self.timeout: float | None = timeout
self.children: list[Item] = []
for item in items:
self.add_item(item)
loop = asyncio.get_running_loop()
self._cancel_callback: Callable[[View], None] | None = None
self._timeout_expiry: float | None = None
self._timeout_task: asyncio.Task[None] | None = None
self._stopped: asyncio.Future[bool] = loop.create_future()
self._store = store
def __repr__(self) -> str:
return f"<{self.__class__.__name__} timeout={self.timeout} children={len(self.children)}>"
async def _timeout_task_impl(self) -> None:
while True:
# Guard just in case someone changes the value of the timeout at runtime
if self.timeout is None:
return
if self._timeout_expiry is None:
return self._dispatch_timeout()
# Check if we've elapsed our currently set timeout
now = time.monotonic()
if now >= self._timeout_expiry:
return self._dispatch_timeout()
# Wait N seconds to see if timeout data has been refreshed
await asyncio.sleep(self._timeout_expiry - now)
@property
def _expires_at(self) -> float | None:
if self.timeout:
return time.monotonic() + self.timeout
return None
def _dispatch_timeout(self):
raise NotImplementedError
def to_components(self) -> list[dict[str, Any]]:
return [item.to_component_dict() for item in self.children]
def get_item(self, custom_id: str | int) -> Item | None:
"""Gets an item from this structure. Roughly equal to `utils.get(self.children, ...)`.
If an :class:`int` is provided, the item will be retrieved by ``id``, otherwise by ``custom_id``.
This method will also search nested items.
Parameters
----------
custom_id: Union[:class:`str`, :class:`int`]
The id of the item to get
Returns
-------
Optional[:class:`Item`]
The item with the matching ``custom_id`` or ``id`` if it exists.
"""
if not custom_id:
return None
attr = "id" if isinstance(custom_id, int) else "custom_id"
child = find(lambda i: getattr(i, attr, None) == custom_id, self.children)
if not child:
for i in self.children:
if hasattr(i, "get_item"):
if child := i.get_item(custom_id):
return child
return child
def add_item(self, item: Item) -> Self:
raise NotImplementedError
def remove_item(self, item: Item) -> Self:
raise NotImplementedError
def clear_items(self) -> None:
raise NotImplementedError
async def on_timeout(self) -> None:
"""|coro|
A callback that is called when this structure's timeout elapses without being explicitly stopped.
"""
@@ -0,0 +1,150 @@
"""
The MIT License (MIT)
Copyright (c) 2021-present Pycord Development
Permission is hereby granted, free of charge, to any person obtaining a
copy of this software and associated documentation files (the "Software"),
to deal in the Software without restriction, including without limitation
the rights to use, copy, modify, merge, publish, distribute, sublicense,
and/or sell copies of the Software, and to permit persons to whom the
Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
DEALINGS IN THE SOFTWARE.
"""
from __future__ import annotations
from typing import TYPE_CHECKING, TypeVar
from urllib.parse import urlparse
from ..components import FileComponent, UnfurledMediaItem, _component_factory
from ..enums import ComponentType
from .item import ViewItem
__all__ = ("File",)
if TYPE_CHECKING:
from ..types.components import FileComponent as FileComponentPayload
from .view import DesignerView
F = TypeVar("F", bound="File")
V = TypeVar("V", bound="DesignerView", covariant=True)
class File(ViewItem[V]):
"""Represents a UI File.
.. note::
This component does not show media previews. Use :class:`MediaGallery` for previews instead.
.. versionadded:: 2.7
Parameters
----------
url: :class:`str`
The URL of this file. This must be an ``attachment://`` URL referring to a local file used with :class:`~discord.File`.
spoiler: Optional[:class:`bool`]
Whether this file has the spoiler overlay.
id: Optional[:class:`int`]
The file component's ID.
"""
__item_repr_attributes__: tuple[str, ...] = (
"file",
"spoiler",
"id",
)
def __init__(self, url: str, *, spoiler: bool = False, id: int | None = None):
super().__init__()
file = UnfurledMediaItem(url)
self._underlying = self._generate_underlying(
id=id,
file=file,
spoiler=spoiler,
)
def _generate_underlying(
self,
file: UnfurledMediaItem | None = None,
spoiler: bool | None = None,
id: int | None = None,
) -> FileComponent:
super()._generate_underlying(FileComponent)
return FileComponent._raw_construct(
type=ComponentType.file,
id=id or self.id,
file=file or self.file,
spoiler=spoiler if spoiler is not None else self.spoiler,
)
@property
def file(self) -> UnfurledMediaItem:
"""The file's unerlying media item."""
return self.underlying.file
@file.setter
def file(self, value: UnfurledMediaItem) -> None:
self.underlying.file = value
@property
def url(self) -> str:
"""The URL of this file's underlying media. This must be an ``attachment://`` URL that references a :class:`~discord.File`."""
return self.underlying.file and self.underlying.file.url
@url.setter
def url(self, value: str) -> None:
self.underlying.file.url = value
@property
def spoiler(self) -> bool:
"""Whether the file has the spoiler overlay. Defaults to ``False``."""
return self.underlying.spoiler
@spoiler.setter
def spoiler(self, spoiler: bool) -> None:
self.underlying.spoiler = spoiler
@property
def name(self) -> str | None:
"""The name of this file, if provided by Discord."""
return self.underlying.name
@property
def size(self) -> int | None:
"""The size of this file in bytes, if provided by Discord."""
return self.underlying.size
def refresh_component(self, component: FileComponent) -> None:
original = self.underlying.file
component.file._static_url = original._static_url
self.underlying = component
def to_component_dict(self) -> FileComponentPayload:
return super().to_component_dict()
@classmethod
def from_component(cls: type[F], component: FileComponent) -> F:
url = component.file and component.file.url
if not url.startswith("attachment://"):
url = "attachment://" + urlparse(url).path.rsplit("/", 1)[-1]
return cls(
url,
spoiler=component.spoiler,
id=component.id,
)
callback = None
@@ -0,0 +1,199 @@
"""
The MIT License (MIT)
Copyright (c) 2021-present Pycord Development
Permission is hereby granted, free of charge, to any person obtaining a
copy of this software and associated documentation files (the "Software"),
to deal in the Software without restriction, including without limitation
the rights to use, copy, modify, merge, publish, distribute, sublicense,
and/or sell copies of the Software, and to permit persons to whom the
Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
DEALINGS IN THE SOFTWARE.
"""
from __future__ import annotations
import os
from typing import TYPE_CHECKING
from ..components import FileUpload as FileUploadComponent
from ..enums import ComponentType
from ..message import Attachment
from .item import ModalItem
__all__ = ("FileUpload",)
if TYPE_CHECKING:
from ..interactions import Interaction
from ..types.components import FileUploadComponent as FileUploadComponentPayload
class FileUpload(ModalItem):
"""Represents a UI File Upload component.
.. versionadded:: 2.7
Parameters
----------
custom_id: Optional[:class:`str`]
The ID of the file upload field that gets received during an interaction.
min_values: Optional[:class:`int`]
The minimum number of files that must be uploaded.
Defaults to 0 and must be between 0 and 10, inclusive.
max_values: Optional[:class:`int`]
The maximum number of files that can be uploaded.
Must be between 1 and 10, inclusive.
required: Optional[:class:`bool`]
Whether the file upload field is required or not. Defaults to ``True``.
id: Optional[:class:`int`]
The file upload field's ID.
"""
__item_repr_attributes__: tuple[str, ...] = (
"required",
"min_values",
"max_values",
"custom_id",
"id",
)
def __init__(
self,
*,
custom_id: str | None = None,
min_values: int | None = None,
max_values: int | None = None,
required: bool = True,
id: int | None = None,
):
super().__init__()
if min_values and (min_values < 0 or min_values > 10):
raise ValueError("min_values must be between 0 and 10")
if max_values and (max_values < 1 or max_values > 10):
raise ValueError("max_values must be between 1 and 10")
if custom_id is not None and not isinstance(custom_id, str):
raise TypeError(
f"expected custom_id to be str, not {custom_id.__class__.__name__}"
)
if not isinstance(required, bool):
raise TypeError(f"required must be bool not {required.__class__.__name__}") # type: ignore
custom_id = os.urandom(16).hex() if custom_id is None else custom_id
self._attachments: list[Attachment] | None = None
self._underlying: FileUploadComponent = self._generate_underlying(
custom_id=custom_id,
min_values=min_values,
max_values=max_values,
required=required,
id=id,
)
def _generate_underlying(
self,
custom_id: str | None = None,
min_values: int | None = None,
max_values: int | None = None,
required: bool | None = None,
id: int | None = None,
) -> FileUploadComponent:
super()._generate_underlying(FileUploadComponent)
return FileUploadComponent._raw_construct(
type=ComponentType.file_upload,
custom_id=custom_id or self.custom_id,
min_values=min_values if min_values is not None else self.min_values,
max_values=max_values if max_values is not None else self.max_values,
required=required if required is not None else self.required,
id=id or self.id,
)
@property
def custom_id(self) -> str:
"""The custom id that gets received during an interaction."""
return self.underlying.custom_id
@custom_id.setter
def custom_id(self, value: str):
if not isinstance(value, str):
raise TypeError(f"custom_id must be str not {value.__class__.__name__}")
if len(value) > 100:
raise ValueError("custom_id must be 100 characters or fewer")
self.underlying.custom_id = value
@property
def min_values(self) -> int | None:
"""The minimum number of files that must be uploaded. Defaults to 0."""
return self.underlying.min_values
@min_values.setter
def min_values(self, value: int | None):
if value and not isinstance(value, int):
raise TypeError(f"min_values must be None or int not {value.__class__.__name__}") # type: ignore
if value and (value < 0 or value > 10):
raise ValueError("min_values must be between 0 and 10")
self.underlying.min_values = value
@property
def max_values(self) -> int | None:
"""The maximum number of files that can be uploaded."""
return self.underlying.max_values
@max_values.setter
def max_values(self, value: int | None):
if value and not isinstance(value, int):
raise TypeError(f"max_values must be None or int not {value.__class__.__name__}") # type: ignore
if value and (value < 1 or value > 10):
raise ValueError("max_values must be between 1 and 10")
self.underlying.max_values = value
@property
def required(self) -> bool:
"""Whether the input file upload is required or not. Defaults to ``True``."""
return self.underlying.required
@required.setter
def required(self, value: bool):
if not isinstance(value, bool):
raise TypeError(f"required must be bool not {value.__class__.__name__}") # type: ignore
self.underlying.required = bool(value)
@property
def values(self) -> list[Attachment] | None:
"""The files that were uploaded to the field. This will be ``None`` if the file upload has not been submitted via a modal yet."""
return self._attachments
def to_component_dict(self) -> FileUploadComponentPayload:
return self.underlying.to_dict()
def refresh_from_modal(self, interaction: Interaction, data: dict) -> None:
values = data.get("values", [])
self._attachments = [
Attachment(
state=interaction._state,
data=interaction.data["resolved"]["attachments"][attachment_id],
)
for attachment_id in values
]
@classmethod
def from_component(
cls: type[FileUpload], component: FileUploadComponent
) -> FileUpload:
return cls(
custom_id=component.custom_id,
min_values=component.min_values,
max_values=component.max_values,
required=component.required,
id=component.id,
)
@@ -0,0 +1,299 @@
"""
The MIT License (MIT)
Copyright (c) 2021-present Pycord Development
Permission is hereby granted, free of charge, to any person obtaining a
copy of this software and associated documentation files (the "Software"),
to deal in the Software without restriction, including without limitation
the rights to use, copy, modify, merge, publish, distribute, sublicense,
and/or sell copies of the Software, and to permit persons to whom the
Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
DEALINGS IN THE SOFTWARE.
"""
from __future__ import annotations
import os
from typing import TYPE_CHECKING
from ..components import InputText as InputTextComponent
from ..enums import ComponentType, InputTextStyle
from .item import ModalItem
__all__ = ("InputText", "TextInput")
if TYPE_CHECKING:
from ..interactions import Interaction
from ..types.components import InputText as InputTextComponentPayload
class InputText(ModalItem):
"""Represents a UI text input field.
.. versionadded:: 2.0
Parameters
----------
style: :class:`~discord.InputTextStyle`
The style of the input text field.
custom_id: Optional[:class:`str`]
The ID of the input text field that gets received during an interaction.
label: :class:`str`
The label for the input text field.
Must be 45 characters or fewer.
placeholder: Optional[:class:`str`]
The placeholder text that is shown if nothing is selected, if any.
Must be 100 characters or fewer.
min_length: Optional[:class:`int`]
The minimum number of characters that must be entered.
Defaults to 0 and must be less than 4000.
max_length: Optional[:class:`int`]
The maximum number of characters that can be entered.
Must be between 1 and 4000.
required: Optional[:class:`bool`]
Whether the input text field is required or not. Defaults to ``True``.
value: Optional[:class:`str`]
Pre-fills the input text field with this value.
Must be 4000 characters or fewer.
row: Optional[:class:`int`]
The relative row this input text field belongs to. A modal dialog can only have 5
rows. By default, items are arranged automatically into those 5 rows. If you'd
like to control the relative positioning of the row then passing an index is advised.
For example, row=1 will show up before row=2. Defaults to ``None``, which is automatic
ordering. The row number must be between 0 and 4 (i.e. zero indexed).
"""
__item_repr_attributes__: tuple[str, ...] = (
"label",
"placeholder",
"value",
"required",
"style",
"min_length",
"max_length",
"custom_id",
"id",
)
def __init__(
self,
*,
style: InputTextStyle = InputTextStyle.short,
custom_id: str | None = None,
label: str | None = None,
placeholder: str | None = None,
min_length: int | None = None,
max_length: int | None = None,
required: bool | None = True,
value: str | None = None,
row: int | None = None,
id: int | None = None,
):
super().__init__()
if label and len(str(label)) > 45:
raise ValueError("label must be 45 characters or fewer")
if min_length and (min_length < 0 or min_length > 4000):
raise ValueError("min_length must be between 0 and 4000")
if max_length and (max_length < 0 or max_length > 4000):
raise ValueError("max_length must be between 1 and 4000")
if value and len(str(value)) > 4000:
raise ValueError("value must be 4000 characters or fewer")
if placeholder and len(str(placeholder)) > 100:
raise ValueError("placeholder must be 100 characters or fewer")
if not isinstance(custom_id, str) and custom_id is not None:
raise TypeError(
f"expected custom_id to be str, not {custom_id.__class__.__name__}"
)
custom_id = os.urandom(16).hex() if custom_id is None else custom_id
self._input_value = False
self.row = row
self._rendered_row: int | None = None
self._underlying = self._generate_underlying(
style=style,
custom_id=custom_id,
label=label,
placeholder=placeholder,
min_length=min_length,
max_length=max_length,
required=required,
value=value,
id=id,
)
def _generate_underlying(
self,
style: InputTextStyle | None = None,
custom_id: str | None = None,
label: str | None = None,
placeholder: str | None = None,
min_length: int | None = None,
max_length: int | None = None,
required: bool | None = None,
value: str | None = None,
id: int | None = None,
) -> InputTextComponent:
super()._generate_underlying(InputTextComponent)
return InputTextComponent._raw_construct(
type=ComponentType.input_text,
style=style or self.style,
custom_id=custom_id or self.custom_id,
label=label or self.label,
placeholder=placeholder or self.placeholder,
min_length=min_length if min_length is not None else self.min_length,
max_length=max_length if max_length is not None else self.max_length,
required=required if required is not None else self.required,
value=value or self.value,
id=id or self.id,
)
@property
def style(self) -> InputTextStyle:
"""The style of the input text field."""
return self.underlying.style
@style.setter
def style(self, value: InputTextStyle):
if not isinstance(value, InputTextStyle):
raise TypeError(
f"style must be of type InputTextStyle not {value.__class__.__name__}"
)
self.underlying.style = value
@property
def custom_id(self) -> str:
"""The ID of the input text field that gets received during an interaction."""
return self.underlying.custom_id
@custom_id.setter
def custom_id(self, value: str):
if not isinstance(value, str):
raise TypeError(f"custom_id must be str not {value.__class__.__name__}")
if len(value) > 100:
raise ValueError("custom_id must be 100 characters or fewer")
self.underlying.custom_id = value
@property
def label(self) -> str:
"""The label of the input text field."""
return self.underlying.label
@label.setter
def label(self, value: str):
if not isinstance(value, str):
raise TypeError(f"label should be str not {value.__class__.__name__}")
if len(value) > 45:
raise ValueError("label must be 45 characters or fewer")
self.underlying.label = value
@property
def placeholder(self) -> str | None:
"""The placeholder text that is shown before anything is entered, if any."""
return self.underlying.placeholder
@placeholder.setter
def placeholder(self, value: str | None):
if value and not isinstance(value, str):
raise TypeError(f"placeholder must be None or str not {value.__class__.__name__}") # type: ignore
if value and len(value) > 100:
raise ValueError("placeholder must be 100 characters or fewer")
self.underlying.placeholder = value
@property
def min_length(self) -> int | None:
"""The minimum number of characters that must be entered. Defaults to 0."""
return self.underlying.min_length
@min_length.setter
def min_length(self, value: int | None):
if value and not isinstance(value, int):
raise TypeError(f"min_length must be None or int not {value.__class__.__name__}") # type: ignore
if value and (value < 0 or value) > 4000:
raise ValueError("min_length must be between 0 and 4000")
self.underlying.min_length = value
@property
def max_length(self) -> int | None:
"""The maximum number of characters that can be entered."""
return self.underlying.max_length
@max_length.setter
def max_length(self, value: int | None):
if value and not isinstance(value, int):
raise TypeError(f"min_length must be None or int not {value.__class__.__name__}") # type: ignore
if value and (value <= 0 or value > 4000):
raise ValueError("max_length must be between 1 and 4000")
self.underlying.max_length = value
@property
def required(self) -> bool | None:
"""Whether the input text field is required or not. Defaults to ``True``."""
return self.underlying.required
@required.setter
def required(self, value: bool | None):
if not isinstance(value, bool):
raise TypeError(f"required must be bool not {value.__class__.__name__}") # type: ignore
self.underlying.required = bool(value)
@property
def value(self) -> str | None:
"""The value entered in the text field."""
if self._input_value is not False:
# only False on init, otherwise the value was either set or cleared
return self._input_value # type: ignore
return self.underlying.value
@value.setter
def value(self, value: str | None):
if value and not isinstance(value, str):
raise TypeError(f"value must be None or str not {value.__class__.__name__}") # type: ignore
if value and len(str(value)) > 4000:
raise ValueError("value must be 4000 characters or fewer")
self.underlying.value = value
@property
def width(self) -> int:
return 5
def to_component_dict(self) -> InputTextComponentPayload:
return super().to_component_dict()
def refresh_state(self, data) -> None:
self._input_value = data.get("value", None)
def refresh_from_modal(
self, interaction: Interaction, data: InputTextComponentPayload
) -> None:
return self.refresh_state(data)
@classmethod
def from_component(
cls: type[InputText], component: InputTextComponent
) -> InputText:
return cls(
style=component.style,
custom_id=component.custom_id,
label=component.label,
placeholder=component.placeholder,
min_length=component.min_length,
max_length=component.max_length,
required=component.required,
value=component.value,
id=component.id,
)
TextInput = InputText
@@ -0,0 +1,269 @@
"""
The MIT License (MIT)
Copyright (c) 2015-2021 Rapptz
Copyright (c) 2021-present Pycord Development
Permission is hereby granted, free of charge, to any person obtaining a
copy of this software and associated documentation files (the "Software"),
to deal in the Software without restriction, including without limitation
the rights to use, copy, modify, merge, publish, distribute, sublicense,
and/or sell copies of the Software, and to permit persons to whom the
Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
DEALINGS IN THE SOFTWARE.
"""
from __future__ import annotations
from typing import TYPE_CHECKING, Any, Callable, Coroutine, Generic, TypeVar
from ..interactions import Interaction
__all__ = (
"Item",
"ViewItem",
"ModalItem",
)
from ..utils import warn_deprecated
if TYPE_CHECKING:
from ..components import Component
from ..enums import ComponentType
from .core import ItemInterface
from .modal import BaseModal
from .view import BaseView
I = TypeVar("I", bound="Item")
T = TypeVar("T", bound="ItemInterface", covariant=True)
V = TypeVar("V", bound="BaseView", covariant=True)
M = TypeVar("M", bound="BaseModal", covariant=True)
ItemCallbackType = Callable[[Any, I, Interaction], Coroutine[Any, Any, Any]]
class Item(Generic[T]):
"""Represents the base UI item that all UI components inherit from.
.. versionadded:: 2.0
.. versionchanged:: 2.7
Now used as base class for :class:`ViewItem` and :class:`ModalItem`.
"""
__item_repr_attributes__: tuple[str, ...] = ("id",)
def __init__(self):
self._underlying: Component | None = None
self._provided_custom_id: bool = False
self.parent: Item | ItemInterface | None = None
def to_component_dict(self) -> dict[str, Any]:
if not self.underlying:
raise NotImplementedError
return self.underlying.to_dict()
def refresh_component(self, component: Component) -> None:
self.underlying = component
def refresh_state(self, interaction: Interaction) -> None:
return None
@classmethod
def from_component(cls: type[I], component: Component) -> I:
return cls()
@property
def underlying(self) -> Component:
return self._underlying
@underlying.setter
def underlying(self, value: Component) -> None:
self._underlying = value
@property
def type(self) -> ComponentType:
"""The underlying component's type."""
if not self.underlying:
raise NotImplementedError
return self.underlying.type
def _generate_underlying(self, cls: type[Component]) -> Component:
if not self._underlying:
self._underlying = cls._raw_construct()
return self._underlying
def is_dispatchable(self) -> bool:
return False
def is_storable(self) -> bool:
return False
def is_persistent(self) -> bool:
return not self.is_dispatchable() or self._provided_custom_id
def copy_text(self) -> str:
return ""
def __repr__(self) -> str:
attrs = " ".join(
f"{key}={getattr(self, key)!r}" for key in self.__item_repr_attributes__
)
return f"<{self.__class__.__name__} {attrs}>"
@property
def id(self) -> int | None:
"""Gets this item's ID.
This can be set by the user when constructing an Item. If not, Discord will automatically provide one when the item's parent is sent.
Returns
-------
Optional[:class:`int`]
The ID of this item, or ``None`` if the user didn't set one.
"""
return self.underlying and self.underlying.id
@id.setter
def id(self, value) -> None:
if not self.underlying:
return
self.underlying.id = value
class ViewItem(Item[V]):
"""Represents an item used in Views.
The following are the original items supported in :class:`discord.ui.View`:
- :class:`discord.ui.Button`
- :class:`discord.ui.Select`
And the following are new items under the "Components V2" specification for use in :class:`discord.ui.DesignerView`:
- :class:`discord.ui.Section`
- :class:`discord.ui.TextDisplay`
- :class:`discord.ui.Thumbnail`
- :class:`discord.ui.MediaGallery`
- :class:`discord.ui.File`
- :class:`discord.ui.Separator`
- :class:`discord.ui.Container`
Additionally, :class:`discord.ui.ActionRow` should be used in :class:`discord.ui.DesignerView` to support :class:`discord.ui.Button` and :class:`discord.ui.Select`.
.. versionadded:: 2.7
"""
def __init__(self):
super().__init__()
self._view: V | None = None
self.parent: ViewItem | BaseView | None = None
@property
def row(self) -> int | None:
warn_deprecated("Accessing .row from CV2 Items", since="2.7.1", removed="3.0")
return None
@row.setter
def row(self, value: int | None) -> None:
warn_deprecated("Setting .row on CV2 Items", since="2.7.1", removed="3.0")
@property
def width(self) -> int:
return 1
@property
def view(self) -> V | None:
"""Gets the parent view associated with this item.
The view refers to the structure that holds this item. This is typically set
automatically when the item is added to a view.
Returns
-------
Optional[:class:`BaseView`]
The parent view of this item, or ``None`` if the item is not attached to any view.
"""
if self._view:
return self._view
if self.parent:
from .view import BaseView
if isinstance(self.parent, BaseView):
return self.parent
return self.parent.view
return None
@view.setter
def view(self, value: V | None) -> None:
warn_deprecated("Manually setting .view", since="2.7", removed="3.0")
self._view = value
async def callback(self, interaction: Interaction):
"""|coro|
The callback associated with this UI item.
This can be overridden by subclasses.
Parameters
----------
interaction: :class:`.Interaction`
The interaction that triggered this UI item.
"""
class ModalItem(Item[M]):
"""Represents an item used in Modals.
:class:`discord.ui.InputText` is the original item supported in :class:`discord.ui.Modal`.
The following are newly available in :class:`discord.ui.DesignerModal`:
- :class:`discord.ui.Label`
- :class:`discord.ui.TextDisplay`
And :class:`discord.ui.Label` should be used in :class:`discord.ui.DesignerModal` to add the following items:
- :class:`discord.ui.InputText`
- :class:`discord.ui.Select`
- :class:`discord.ui.FileUpload`
- :class:`discord.ui.RadioGroup`
- :class:`discord.ui.CheckboxGroup`
- :class:`discord.ui.Checkbox`
.. versionadded:: 2.7
"""
def __init__(self):
super().__init__()
self._modal: M | None = None
self.parent: ModalItem | BaseModal | None = self.modal
def refresh_from_modal(self, interaction: Interaction, data: dict) -> None:
return None
@property
def modal(self) -> M | None:
"""Gets the parent modal associated with this item. This is typically set
automatically when the item is added to a modal.
Returns
-------
Optional[:class:`BaseModal`]
The parent modal of this item, or ``None`` if the item is not attached to any modal.
"""
return self._modal
@modal.setter
def modal(self, value) -> None:
self._modal = value
@@ -0,0 +1,566 @@
"""
The MIT License (MIT)
Copyright (c) 2021-present Pycord Development
Permission is hereby granted, free of charge, to any person obtaining a
copy of this software and associated documentation files (the "Software"),
to deal in the Software without restriction, including without limitation
the rights to use, copy, modify, merge, publish, distribute, sublicense,
and/or sell copies of the Software, and to permit persons to whom the
Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
DEALINGS IN THE SOFTWARE.
"""
from __future__ import annotations
from collections.abc import Sequence
from typing import TYPE_CHECKING, Iterator, Literal, TypeVar, overload
from ..components import (
CheckboxGroupOption,
)
from ..components import Label as LabelComponent
from ..components import (
RadioGroupOption,
SelectDefaultValue,
SelectOption,
_component_factory,
)
from ..enums import ButtonStyle, ChannelType, ComponentType, InputTextStyle
from ..utils import find, get
from .button import Button
from .checkbox import Checkbox
from .checkbox_group import CheckboxGroup
from .file_upload import FileUpload
from .input_text import InputText
from .item import ItemCallbackType, ModalItem
from .radio_group import RadioGroup
from .select import Select
__all__ = ("Label",)
if TYPE_CHECKING:
from typing_extensions import Self
from ..emoji import AppEmoji, GuildEmoji
from ..interactions import Interaction
from ..partial_emoji import PartialEmoji, _EmojiTag
from ..types.components import LabelComponent as LabelComponentPayload
from .modal import DesignerModal
L = TypeVar("L", bound="Label")
M = TypeVar("M", bound="DesignerModal", covariant=True)
class Label(ModalItem[M]):
"""Represents a UI Label used in :class:`discord.ui.DesignerModal`.
The items currently supported are as follows:
- :class:`discord.ui.Select`
- :class:`discord.ui.InputText`
- :class:`discord.ui.FileUpload`
- :class:`discord.ui.RadioGroup`
- :class:`discord.ui.CheckboxGroup`
- :class:`discord.ui.Checkbox`
.. versionadded:: 2.7
Parameters
----------
item: :class:`ModalItem`
The initial item attached to this label.
label: :class:`str`
The label text. Must be 45 characters or fewer.
description: Optional[:class:`str`]
The description for this label. Must be 100 characters or fewer.
id: Optional[:class:`int`]
The label's ID.
Attributes
----------
item: :class:`ViewItem`
The label's attached item.
"""
__item_repr_attributes__: tuple[str, ...] = (
"item",
"id",
"label",
"description",
)
def __init__(
self,
label: str,
item: ModalItem = None,
*,
description: str | None = None,
id: int | None = None,
):
super().__init__()
self.item: ModalItem = None
self._underlying = self._generate_underlying(
id=id,
label=label,
description=description,
)
if item:
self.set_item(item)
@ModalItem.modal.setter
def modal(self, value):
self._modal = value
if self.item:
self.item.modal = value
def _set_component_from_item(self, item: ModalItem):
self.underlying.component = item._generate_underlying()
def _generate_underlying(
self,
label: str | None = None,
description: str | None = None,
id: int | None = None,
) -> LabelComponent:
super()._generate_underlying(LabelComponent)
label = LabelComponent._raw_construct(
type=ComponentType.label,
id=id or self.id,
component=None,
label=label or self.label,
description=description or self.description,
)
if self.item:
label.component = self.item._generate_underlying()
return label
def set_item(self, item: ModalItem) -> Self:
"""Set this label's item.
Parameters
----------
item: Union[:class:`ModalItem`]
The item to set.
Raises
------
TypeError
A :class:`ModalItem` was not passed.
"""
if not isinstance(item, ModalItem):
raise TypeError(f"expected ModalItem not {item.__class__!r}")
if isinstance(item, InputText) and item.label:
raise ValueError(f"InputText.label cannot be set inside Label")
if self.modal:
item.modal = self.modal
item.parent = self
self.item = item
self._set_component_from_item(item)
return self
def get_item(self, id: str | int) -> ModalItem | None:
"""Get the item from this label if it matches the provided id.
If an ``int`` is provided, the item will match by ``id``, otherwise by ``custom_id``.
Parameters
----------
id: Union[:class:`str`, :class:`int`]
The id or custom_id of the item to match.
Returns
-------
Optional[:class:`ModalItem`]
The item if its ``id`` or ``custom_id`` matches.
"""
if not id:
return None
attr = "id" if isinstance(id, int) else "custom_id"
if getattr(self.item, attr, None) != id:
return None
return self.item
def set_input_text(
self,
*,
style: InputTextStyle = InputTextStyle.short,
custom_id: str | None = None,
placeholder: str | None = None,
min_length: int | None = None,
max_length: int | None = None,
required: bool | None = True,
value: str | None = None,
id: int | None = None,
) -> Self:
"""Set this label's item to an input text.
To set a pre-existing :class:`InputText`, use the
:meth:`set_item` method, instead.
Parameters
----------
style: :class:`~discord.InputTextStyle`
The style of the input text field.
custom_id: Optional[:class:`str`]
The ID of the input text field that gets received during an interaction.
placeholder: Optional[:class:`str`]
The placeholder text that is shown if nothing is selected, if any.
Must be 100 characters or fewer.
min_length: Optional[:class:`int`]
The minimum number of characters that must be entered.
Defaults to 0 and must be less than 4000.
max_length: Optional[:class:`int`]
The maximum number of characters that can be entered.
Must be between 1 and 4000.
required: Optional[:class:`bool`]
Whether the input text field is required or not. Defaults to ``True``.
value: Optional[:class:`str`]
Pre-fills the input text field with this value.
Must be 4000 characters or fewer.
id: Optional[:class:`int`]
The button's ID.
"""
text = InputText(
style=style,
custom_id=custom_id,
placeholder=placeholder,
min_length=min_length,
max_length=max_length,
required=required,
value=value,
id=id,
)
return self.set_item(text)
@overload
def set_select(
self,
select_type: Literal[ComponentType.string_select] = ...,
*,
custom_id: str | None = ...,
placeholder: str | None = ...,
min_values: int = ...,
max_values: int = ...,
options: list[SelectOption] | None = ...,
required: bool = ...,
id: int | None = ...,
) -> None: ...
@overload
def set_select(
self,
select_type: Literal[ComponentType.channel_select] = ...,
*,
custom_id: str | None = ...,
placeholder: str | None = ...,
min_values: int = ...,
max_values: int = ...,
channel_types: list[ChannelType] | None = ...,
required: bool = ...,
id: int | None = ...,
default_values: Sequence[SelectDefaultValue] | None = ...,
) -> None: ...
@overload
def set_select(
self,
select_type: Literal[
ComponentType.user_select,
ComponentType.role_select,
ComponentType.mentionable_select,
] = ...,
*,
custom_id: str | None = ...,
placeholder: str | None = ...,
min_values: int = ...,
max_values: int = ...,
required: bool = ...,
id: int | None = ...,
default_values: Sequence[SelectDefaultValue] | None = ...,
) -> None: ...
def set_select(
self,
select_type: ComponentType = ComponentType.string_select,
*,
custom_id: str | None = None,
placeholder: str | None = None,
min_values: int = 1,
max_values: int = 1,
options: list[SelectOption] | None = None,
channel_types: list[ChannelType] | None = None,
required: bool = True,
id: int | None = None,
default_values: Sequence[SelectDefaultValue] | None = None,
) -> Self:
"""Set this label's item to a select menu.
Parameters
----------
select_type: :class:`discord.ComponentType`
The type of select to create. Must be one of
:attr:`discord.ComponentType.string_select`, :attr:`discord.ComponentType.user_select`,
:attr:`discord.ComponentType.role_select`, :attr:`discord.ComponentType.mentionable_select`,
or :attr:`discord.ComponentType.channel_select`.
custom_id: :class:`str`
The custom ID of the select menu that gets received during an interaction.
If not given then one is generated for you.
placeholder: Optional[:class:`str`]
The placeholder text that is shown if nothing is selected, if any.
min_values: :class:`int`
The minimum number of items that must be chosen for this select menu.
Defaults to 1 and must be between 1 and 25.
max_values: :class:`int`
The maximum number of items that must be chosen for this select menu.
Defaults to 1 and must be between 1 and 25.
options: List[:class:`discord.SelectOption`]
A list of options that can be selected in this menu.
Only valid for selects of type :attr:`discord.ComponentType.string_select`.
channel_types: List[:class:`discord.ChannelType`]
A list of channel types that can be selected in this menu.
Only valid for selects of type :attr:`discord.ComponentType.channel_select`.
required: :class:`bool`
Whether the select is required or not. Defaults to ``True``.
id: Optional[:class:`int`]
The select menu's ID.
default_values: Optional[Sequence[Union[:class:`discord.SelectDefaultValue`, :class:`discord.abc.Snowflake`]]]
The default values of this select. Only applicable if :attr:`.select_type` is not :attr:`discord.ComponentType.string_select`.
These can be either :class:`discord.SelectDefaultValue` instances or models, which will be converted into :class:`discord.SelectDefaultValue`
instances.
"""
select = Select(
select_type=select_type,
custom_id=custom_id,
placeholder=placeholder,
min_values=min_values,
max_values=max_values,
options=options or [],
channel_types=channel_types or [],
required=required,
id=id,
default_values=default_values,
)
return self.set_item(select)
def set_file_upload(
self,
*,
custom_id: str | None = None,
min_values: int | None = None,
max_values: int | None = None,
required: bool | None = True,
id: int | None = None,
) -> Self:
"""Set this label's item to a file upload.
To set a pre-existing :class:`FileUpload`, use the
:meth:`set_item` method, instead.
Parameters
----------
custom_id: Optional[:class:`str`]
The ID of the input text field that gets received during an interaction.
min_values: Optional[:class:`int`]
The minimum number of files that must be uploaded.
Defaults to 0 and must be between 0 and 10, inclusive.
max_values: Optional[:class:`int`]
The maximum number of files that can be uploaded.
Must be between 1 and 10, inclusive.
required: Optional[:class:`bool`]
Whether the file upload field is required or not. Defaults to ``True``.
id: Optional[:class:`int`]
The file upload field's ID.
"""
upload = FileUpload(
custom_id=custom_id,
min_values=min_values,
max_values=max_values,
required=required,
id=id,
)
return self.set_item(upload)
def set_radio_group(
self,
*,
custom_id: str | None = None,
options: list[RadioGroupOption] | None = None,
required: bool | None = True,
id: int | None = None,
) -> Self:
"""Set this label's item to a radio group.
To set a pre-existing :class:`RadioGroup`, use the
:meth:`set_item` method, instead.
Parameters
----------
custom_id: Optional[:class:`str`]
The ID of the radio group that gets received during an interaction.
options: List[:class:`discord.RadioGroupOption`]
A list of options that can be selected from this group.
required: Optional[:class:`bool`]
Whether an option selection is required or not. Defaults to ``True``.
id: Optional[:class:`int`]
The radio group's ID.
"""
radio = RadioGroup(
custom_id=custom_id,
option=options,
required=required,
id=id,
)
return self.set_item(radio)
def set_checkbox_group(
self,
*,
custom_id: str | None = None,
options: list[CheckboxGroupOption] | None = None,
min_values: int | None = None,
max_values: int | None = None,
required: bool | None = True,
id: int | None = None,
) -> Self:
"""Set this label's item to a checkbox group.
To set a pre-existing :class:`CheckboxGroup`, use the
:meth:`set_item` method, instead.
Parameters
----------
custom_id: Optional[:class:`str`]
The ID of the checkbox group that gets received during an interaction.
options: List[:class:`discord.CheckboxGroupOption`]
A list of options that can be selected in this group.
min_values: Optional[:class:`int`]
The minimum number of options that must be selected.
Defaults to 0 and must be between 0 and 10, inclusive.
max_values: Optional[:class:`int`]
The maximum number of options that can be selected.
Must be between 1 and 10, inclusive.
required: Optional[:class:`bool`]
Whether an option selection is required or not. Defaults to ``True``.
id: Optional[:class:`int`]
The checkbox group's ID.
"""
checkboxes = CheckboxGroup(
custom_id=custom_id,
option=options,
min_values=min_values,
max_values=max_values,
required=required,
id=id,
)
return self.set_item(checkboxes)
def set_checkbox(
self,
*,
custom_id: str | None = None,
default: bool | None = False,
id: int | None = None,
) -> Self:
"""Set this label's item to a checkbox.
To set a pre-existing :class:`Checkbox`, use the
:meth:`set_item` method, instead.
Parameters
----------
custom_id: Optional[:class:`str`]
The ID of the checkbox that gets received during an interaction.
default: Optional[:class:`bool`]
Whether this checkbox is selected by default or not.
id: Optional[:class:`int`]
The checkbox's ID.
"""
checkbox = Checkbox(
custom_id=custom_id,
default=default,
id=id,
)
return self.set_item(checkbox)
@property
def label(self) -> str:
"""The label text. Must be 45 characters or fewer."""
return self.underlying.label
@label.setter
def label(self, value: str) -> None:
self.underlying.label = value
@property
def description(self) -> str | None:
"""The description for this label. Must be 100 characters or fewer."""
return self.underlying.description
@description.setter
def description(self, value: str | None) -> None:
self.underlying.description = value
def is_dispatchable(self) -> bool:
return self.item.is_dispatchable()
def is_persistent(self) -> bool:
return self.item.is_persistent()
def refresh_component(self, component: LabelComponent) -> None:
self.underlying = component
self.item.refresh_component(component.component)
def walk_items(self) -> Iterator[ModalItem]:
yield from [self.item]
def to_component_dict(self) -> LabelComponentPayload:
self._set_component_from_item(self.item)
return super().to_component_dict()
def refresh_from_modal(
self, interaction: Interaction, data: LabelComponentPayload
) -> None:
return self.item.refresh_from_modal(interaction, data.get("component", {}))
@classmethod
def from_component(cls: type[L], component: LabelComponent) -> L:
from .view import _component_to_item
item = _component_to_item(component.component)
return cls(
label=component.label,
item=item,
id=component.id,
description=component.description,
)
@@ -0,0 +1,179 @@
"""
The MIT License (MIT)
Copyright (c) 2021-present Pycord Development
Permission is hereby granted, free of charge, to any person obtaining a
copy of this software and associated documentation files (the "Software"),
to deal in the Software without restriction, including without limitation
the rights to use, copy, modify, merge, publish, distribute, sublicense,
and/or sell copies of the Software, and to permit persons to whom the
Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
DEALINGS IN THE SOFTWARE.
"""
from __future__ import annotations
from typing import TYPE_CHECKING, TypeVar
from ..components import MediaGallery as MediaGalleryComponent
from ..components import MediaGalleryItem
from ..enums import ComponentType
from .item import ViewItem
__all__ = ("MediaGallery",)
if TYPE_CHECKING:
from typing_extensions import Self
from ..types.components import MediaGalleryComponent as MediaGalleryComponentPayload
from .view import DesignerView
M = TypeVar("M", bound="MediaGallery")
V = TypeVar("V", bound="DesignerView", covariant=True)
class MediaGallery(ViewItem[V]):
"""Represents a UI Media Gallery. Galleries may contain up to 10 :class:`MediaGalleryItem` objects.
.. versionadded:: 2.7
Parameters
----------
*items: :class:`~discord.MediaGalleryItem`
The initial items contained in this gallery, up to 10.
id: Optional[:class:`int`]
The gallery's ID.
Attributes
----------
items: List[:class:`~discord.MediaGalleryItem`]
The list of media items in this gallery.
"""
__item_repr_attributes__: tuple[str, ...] = (
"items",
"id",
)
def __init__(self, *items: MediaGalleryItem, id: int | None = None):
super().__init__()
self._underlying = self._generate_underlying(id=id, items=items)
def _generate_underlying(
self, id: int | None = None, items: list[MediaGalleryItem] | None = None
) -> MediaGalleryComponent:
super()._generate_underlying(MediaGalleryComponent)
return MediaGalleryComponent._raw_construct(
type=ComponentType.media_gallery,
id=id or self.id,
items=[i for i in items] if items else [i for i in self.items or []],
)
@property
def items(self) -> list[MediaGalleryItem]:
"""The list of media items in this gallery."""
return self.underlying.items
@items.setter
def items(self, value: list[MediaGalleryItem]) -> None:
if len(value) > 10:
raise ValueError("may not set more than 10 items in a gallery.")
if not all(isinstance(i, MediaGalleryItem) for i in value):
raise TypeError(f"items must be a list of MediaGalleryItem.")
self.underlying.items = value
def append_item(self, item: MediaGalleryItem) -> Self:
"""Adds a :attr:`MediaGalleryItem` to the gallery.
Parameters
----------
item: :class:`MediaGalleryItem`
The gallery 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.items) >= 10:
raise ValueError("maximum number of items exceeded")
if not isinstance(item, MediaGalleryItem):
raise TypeError(f"expected MediaGalleryItem not {item.__class__!r}")
self.underlying.items.append(item)
return self
def add_item(
self,
url: str,
*,
description: str = None,
spoiler: bool = False,
) -> Self:
"""Adds a new media item to the gallery.
Parameters
----------
url: :class:`str`
The URL of the media item. This can either be an arbitrary URL or an ``attachment://`` URL.
description: Optional[:class:`str`]
The media item's description, up to 1024 characters.
spoiler: Optional[:class:`bool`]
Whether the media item has the spoiler overlay.
Raises
------
ValueError
Maximum number of items has been exceeded (10).
"""
if len(self.items) >= 10:
raise ValueError("maximum number of items exceeded")
item = MediaGalleryItem(url, description=description, spoiler=spoiler)
return self.append_item(item)
def remove_item(self, index: int) -> Self:
"""Removes an item from the gallery.
Parameters
----------
index: :class:`int`
The index of the item to remove from the gallery.
"""
try:
self.items.pop(index)
except IndexError:
pass
return self
def to_component_dict(self) -> MediaGalleryComponentPayload:
self._underlying = self._generate_underlying()
return super().to_component_dict()
@classmethod
def from_component(cls: type[M], component: MediaGalleryComponent) -> M:
return cls(*component.items, id=component.id)
callback = None
@@ -0,0 +1,527 @@
"""
The MIT License (MIT)
Copyright (c) 2021-present Pycord Development
Permission is hereby granted, free of charge, to any person obtaining a
copy of this software and associated documentation files (the "Software"),
to deal in the Software without restriction, including without limitation
the rights to use, copy, modify, merge, publish, distribute, sublicense,
and/or sell copies of the Software, and to permit persons to whom the
Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
DEALINGS IN THE SOFTWARE.
"""
from __future__ import annotations
import asyncio
import os
import sys
import time
from functools import partial
from itertools import groupby
from typing import TYPE_CHECKING, Any, Iterator, TypeVar
from ..enums import ComponentType
from ..utils import _get_event_loop, find
from .core import ItemInterface
from .input_text import InputText
from .item import ModalItem
from .label import Label
from .select import Select
from .text_display import TextDisplay
__all__ = (
"BaseModal",
"Modal",
"DesignerModal",
"ModalStore",
)
if TYPE_CHECKING:
from typing_extensions import Self
from ..interactions import Interaction
from ..state import ConnectionState
from ..types.components import Component as ComponentPayload
M = TypeVar("M", bound="Modal", covariant=True)
class BaseModal(ItemInterface):
"""The base class for creating pop-up modals.
.. versionadded:: 2.7
"""
__item_repr_attributes__: tuple[str, ...] = (
"title",
"children",
"timeout",
)
def __init__(
self,
*children: ModalItem,
title: str,
custom_id: str | None = None,
timeout: float | None = None,
store: bool = True,
) -> None:
if not isinstance(custom_id, str) and custom_id is not None:
raise TypeError(
f"expected custom_id to be str, not {custom_id.__class__.__name__}"
)
self._custom_id: str | None = custom_id or os.urandom(16).hex()
if len(title) > 45:
raise ValueError("title must be 45 characters or fewer")
self._children: list[ModalItem] = []
super().__init__(timeout=timeout, store=store)
for item in children:
self.add_item(item)
self._title = title
self.loop = _get_event_loop()
def __repr__(self) -> str:
attrs = " ".join(
f"{key}={getattr(self, key)!r}" for key in self.__item_repr_attributes__
)
return f"<{self.__class__.__name__} {attrs}>"
def _start_listening_from_store(self, store: ModalStore) -> None:
self._cancel_callback = partial(store.remove_modal)
if self.timeout:
loop = asyncio.get_running_loop()
if self._timeout_task is not None:
self._timeout_task.cancel()
self._timeout_expiry = time.monotonic() + self.timeout
self._timeout_task = loop.create_task(self._timeout_task_impl())
def _dispatch_timeout(self):
if self._stopped.done():
return
self._stopped.set_result(True)
self.loop.create_task(
self.on_timeout(), name=f"discord-ui-view-timeout-{self.custom_id}"
)
@property
def title(self) -> str:
"""The title of the modal."""
return self._title
@title.setter
def title(self, value: str):
if len(value) > 45:
raise ValueError("title must be 45 characters or fewer")
if not isinstance(value, str):
raise TypeError(f"expected title to be str, not {value.__class__.__name__}")
self._title = value
@property
def children(self) -> list[ModalItem]:
"""The child items attached to the modal."""
return self._children
@children.setter
def children(self, value: list[ModalItem]):
for item in value:
if not isinstance(item, ModalItem):
raise TypeError(
"all BaseModal children must be ModalItem, not"
f" {item.__class__.__name__}"
)
self._children = value
@property
def custom_id(self) -> str:
"""The ID of the modal that gets received during an interaction."""
return self._custom_id
@custom_id.setter
def custom_id(self, value: str):
if not isinstance(value, str):
raise TypeError(
f"expected custom_id to be str, not {value.__class__.__name__}"
)
if len(value) > 100:
raise ValueError("custom_id must be 100 characters or fewer")
self._custom_id = value
async def callback(self, interaction: Interaction):
"""|coro|
The coroutine that is called when the modal is submitted.
Should be overridden to handle the values submitted by the user.
Parameters
----------
interaction: :class:`~discord.Interaction`
The interaction that submitted the modal.
"""
self.stop()
def add_item(self, item: ModalItem) -> Self:
"""Adds a component to the modal.
Parameters
----------
item: Union[:class:`ModalItem`]
The item to add to the modal
"""
if len(self._children) >= 5:
raise ValueError("You can only have up to 5 items in a modal.")
if not isinstance(item, ModalItem):
raise TypeError(f"expected ModalItem, not {item.__class__!r}")
self._children.append(item)
return self
def remove_item(self, item: ModalItem) -> Self:
"""Removes a component from the modal.
Parameters
----------
item: :class:`ModalItem`
The item to remove from the modal.
"""
try:
self._children.remove(item)
except ValueError:
pass
return self
def stop(self) -> None:
"""Stops listening to interaction events from the modal."""
if not self._stopped.done():
self._stopped.set_result(True)
self._timeout_expiry = None
if self._timeout_task is not None:
self._timeout_task.cancel()
self._timeout_task = None
async def wait(self) -> bool:
"""Waits for the modal to be submitted."""
return await self._stopped
def to_dict(self):
return {
"title": self.title,
"custom_id": self.custom_id,
"components": self.to_components(),
}
async def on_error(self, error: Exception, interaction: Interaction) -> None:
"""|coro|
A callback that is called when the modal's callback fails with an error.
The default implementation prints the traceback to stderr.
Parameters
----------
error: :class:`Exception`
The exception that was raised.
interaction: :class:`~discord.Interaction`
The interaction that led to the failure.
"""
interaction.client.dispatch("modal_error", error, interaction)
async def on_timeout(self) -> None:
"""|coro|
A callback that is called when a modal's timeout elapses without being explicitly stopped.
"""
def walk_children(self) -> Iterator[ModalItem]:
for item in self.children:
if hasattr(item, "walk_items"):
yield from item.walk_items()
else:
yield item
class Modal(BaseModal):
"""Represents a legacy UI modal for InputText components.
This object must be inherited to create a UI within Discord.
.. versionadded:: 2.0
.. versionchanged:: 2.7
Now inherits from :class:`BaseModal`
Parameters
----------
children: Union[:class:`InputText`]
The initial items that are displayed in the modal. Only supports :class:`discord.ui.InputText`; for newer modal features, see :class:`DesignerModal`.
title: :class:`str`
The title of the modal.
Must be 45 characters or fewer.
custom_id: Optional[:class:`str`]
The ID of the modal that gets received during an interaction.
Must be 100 characters or fewer.
timeout: Optional[:class:`float`]
Timeout in seconds from last interaction with the UI before no longer accepting input.
If ``None`` then there is no timeout.
store: Optional[:class:`bool`]
Whether this modal should be stored for callback listening. Setting it to ``False`` will ignore its callback and prevent item values from being refreshed. Defaults to ``True``.
"""
def __init__(
self,
*children: InputText,
title: str,
custom_id: str | None = None,
timeout: float | None = None,
store: bool = True,
) -> None:
super().__init__(
*children, title=title, custom_id=custom_id, timeout=timeout, store=store
)
self._weights = _ModalWeights(self._children)
@property
def children(self) -> list[InputText]:
return self._children
@children.setter
def children(self, value: list[InputText]):
for item in value:
if not isinstance(item, InputText):
raise TypeError(
"all Modal children must be InputText, not"
f" {item.__class__.__name__}"
)
self._weights = _ModalWeights(self._children)
self._children = value
def to_components(self) -> list[dict[str, Any]]:
def key(item: InputText) -> int:
return item._rendered_row or 0
children = sorted(self._children, key=key)
components: list[dict[str, Any]] = []
for _, group in groupby(children, key=key):
children = [item.to_component_dict() for item in group]
if not children:
continue
components.append(
{
"type": 1,
"components": children,
}
)
return components
def add_item(self, item: InputText) -> Self:
"""Adds an InputText component to the modal.
Parameters
----------
item: :class:`InputText`
The item to add to the modal
"""
if not isinstance(item, InputText):
raise TypeError(f"expected InputText not {item.__class__!r}")
self._weights.add_item(item)
super().add_item(item)
return self
def remove_item(self, item: InputText) -> Self:
"""Removes an InputText from the modal.
Parameters
----------
item: Union[class:`InputText`]
The item to remove from the modal.
"""
super().remove_item(item)
try:
self.__weights.remove_item(item)
except ValueError:
pass
return self
def _refresh(self, interaction: Interaction, data: list[ComponentPayload]):
components = [
component
for parent_component in data
for component in parent_component["components"]
]
for component in components:
for child in self.children:
if child.custom_id == component["custom_id"]: # type: ignore
child.refresh_from_modal(interaction, component)
break
class DesignerModal(BaseModal):
"""Represents a UI modal compatible with all modal features.
This object must be inherited to create a UI within Discord.
.. versionadded:: 2.7
Parameters
----------
children: Union[:class:`ModalItem`]
The initial items that are displayed in the modal..
title: :class:`str`
The title of the modal.
Must be 45 characters or fewer.
custom_id: Optional[:class:`str`]
The ID of the modal that gets received during an interaction.
Must be 100 characters or fewer.
timeout: Optional[:class:`float`]
Timeout in seconds from last interaction with the UI before no longer accepting input.
If ``None`` then there is no timeout.
store: Optional[:class:`bool`]
Whether this modal should be stored for callback listening. Setting it to ``False`` will ignore its callback and prevent item values from being refreshed. Defaults to ``True``.
"""
def __init__(
self,
*children: ModalItem,
title: str,
custom_id: str | None = None,
timeout: float | None = None,
store: bool = True,
) -> None:
super().__init__(
*children, title=title, custom_id=custom_id, timeout=timeout, store=store
)
@property
def children(self) -> list[ModalItem]:
return self._children
@children.setter
def children(self, value: list[ModalItem]):
for item in value:
if not isinstance(item, ModalItem):
raise TypeError(
"all DesignerModal children must be ModalItem, not"
f" {item.__class__.__name__}"
)
if isinstance(item, (InputText,)):
raise TypeError(
f"DesignerModal does not accept InputText directly. Use Label instead."
)
self._children = value
def add_item(self, item: ModalItem) -> Self:
"""Adds a component to the modal.
Parameters
----------
item: Union[:class:`ModalItem`]
The item to add to the modal
"""
if isinstance(item, (InputText,)):
raise TypeError(
f"DesignerModal does not accept InputText directly. Use Label instead."
)
super().add_item(item)
return self
def _refresh(self, interaction: Interaction, data: list[ComponentPayload]):
for component, child in zip(data, self.children):
child.refresh_from_modal(interaction, component)
class _ModalWeights:
__slots__ = ("weights",)
def __init__(self, children: list[InputText]):
self.weights: list[int] = [0, 0, 0, 0, 0]
key = lambda i: sys.maxsize if i.row is None else i.row
children = sorted(children, key=key)
for row, group in groupby(children, key=key):
for item in group:
self.add_item(item)
def find_open_space(self, item: InputText) -> int:
for index, weight in enumerate(self.weights):
if weight + item.width <= 5:
return index
raise ValueError("could not find open space for item")
def add_item(self, item: InputText) -> None:
if item.row is not None:
total = self.weights[item.row] + item.width
if total > 5:
raise ValueError(
f"item would not fit at row {item.row} ({total} > 5 width)"
)
self.weights[item.row] = total
item._rendered_row = item.row
else:
index = self.find_open_space(item)
self.weights[index] += item.width
item._rendered_row = index
def remove_item(self, item: InputText) -> None:
if item._rendered_row is not None:
self.weights[item._rendered_row] -= item.width
item._rendered_row = None
def clear(self) -> None:
self.weights = [0, 0, 0, 0, 0]
class ModalStore:
def __init__(self, state: ConnectionState) -> None:
# (user_id, custom_id) : Modal
self._modals: dict[tuple[int, str], BaseModal] = {}
self._state: ConnectionState = state
def add_modal(self, modal: BaseModal, user_id: int):
if not modal._store:
return
self._modals[(user_id, modal.custom_id)] = modal
modal._start_listening_from_store(self)
def remove_modal(self, modal: BaseModal, user_id):
modal.stop()
self._modals.pop((user_id, modal.custom_id))
async def dispatch(self, user_id: int, custom_id: str, interaction: Interaction):
key = (user_id, custom_id)
modal = self._modals.get(key)
if modal is None:
return
interaction.modal = modal
try:
components = interaction.data["components"]
modal._refresh(interaction, components)
await modal.callback(interaction)
self.remove_modal(modal, user_id)
except Exception as e:
return await modal.on_error(e, interaction)
@@ -0,0 +1,236 @@
"""
The MIT License (MIT)
Copyright (c) 2021-present Pycord Development
Permission is hereby granted, free of charge, to any person obtaining a
copy of this software and associated documentation files (the "Software"),
to deal in the Software without restriction, including without limitation
the rights to use, copy, modify, merge, publish, distribute, sublicense,
and/or sell copies of the Software, and to permit persons to whom the
Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
DEALINGS IN THE SOFTWARE.
"""
from __future__ import annotations
import os
from typing import TYPE_CHECKING
from typing_extensions import Self
from ..components import RadioGroup as RadioGroupComponent
from ..components import RadioGroupOption
from ..enums import ComponentType
from ..utils import MISSING
from .item import ModalItem
__all__ = ("RadioGroup",)
if TYPE_CHECKING:
from ..interactions import Interaction
from ..types.components import RadioGroupComponent as RadioGroupComponentPayload
class RadioGroup(ModalItem):
"""Represents a UI Radio Group component.
.. versionadded:: 2.8
Attributes
----------
custom_id: Optional[:class:`str`]
The ID of the radio group that gets received during an interaction.
options: List[:class:`discord.RadioGroupOption`]
A list of options that can be selected from this group. Must provide between 2 and 10 options.
required: Optional[:class:`bool`]
Whether an option selection is required or not. Defaults to ``True``.
id: Optional[:class:`int`]
The radio group's ID.
"""
__item_repr_attributes__: tuple[str, ...] = (
"options",
"required",
"custom_id",
"id",
)
def __init__(
self,
*,
custom_id: str | None = None,
options: list[RadioGroupOption] | None = None,
required: bool = True,
id: int | None = None,
):
super().__init__()
if custom_id is not None and not isinstance(custom_id, str):
raise TypeError(
f"expected custom_id to be str, not {custom_id.__class__.__name__}"
)
if not isinstance(required, bool):
raise TypeError(f"required must be bool not {required.__class__.__name__}")
custom_id = os.urandom(16).hex() if custom_id is None else custom_id
self._selected_value: str | None = None
self._underlying: RadioGroupComponent = self._generate_underlying(
custom_id=custom_id,
options=[],
required=required,
id=id,
)
if options:
self.options = options
def _generate_underlying(
self,
custom_id: str | None = None,
options: list[RadioGroupOption] | None = None,
required: bool | None = None,
id: int | None = None,
) -> RadioGroupComponent:
super()._generate_underlying(RadioGroupComponent)
return RadioGroupComponent._raw_construct(
type=ComponentType.radio_group,
custom_id=custom_id or self.custom_id,
options=options if options is not None else self.options,
required=required if required is not None else self.required,
id=id or self.id,
)
@property
def custom_id(self) -> str:
"""The custom id that gets received during an interaction."""
return self.underlying.custom_id
@custom_id.setter
def custom_id(self, value: str):
if not isinstance(value, str):
raise TypeError(f"custom_id must be str not {value.__class__.__name__}")
if len(value) > 100:
raise ValueError("custom_id must be 100 characters or fewer")
self.underlying.custom_id = value
@property
def required(self) -> bool:
"""Whether an option selection is required or not. Defaults to ``True``"""
return self.underlying.required
@required.setter
def required(self, value: bool):
if not isinstance(value, bool):
raise TypeError(f"required must be bool, not {value.__class__.__name__}")
self.underlying.required = bool(value)
@property
def value(self) -> str | None:
"""The value selected by the user. May return ``None`` if this radio group is optional or has not been sent yet."""
return self._selected_value
@property
def options(self) -> list[RadioGroupOption]:
"""A list of options that can be selected in this group."""
return self.underlying.options
@options.setter
def options(self, value: list[RadioGroupOption]):
if not isinstance(value, list):
raise TypeError("options must be a list of RadioGroupOption")
if not (2 <= len(value) <= 10) and len(value) != 0:
raise ValueError("you must provide between 2 and 10 options.")
if not all(isinstance(obj, RadioGroupOption) for obj in value):
raise TypeError("all list items must subclass RadioGroupOption")
if len([o for o in value if o.default]) > 1:
raise ValueError("only 1 option can be set as default.")
self.underlying.options = value
def add_option(
self,
*,
label: str,
value: str = MISSING,
description: str | None = None,
default: bool = False,
) -> Self:
"""Adds an option to the radio group.
To append a pre-existing :class:`discord.RadioGroupOption` use the
:meth:`append_option` method instead.
Parameters
----------
label: :class:`str`
The label of the option. This is displayed to users.
Can only be up to 100 characters.
value: :class:`str`
The value of the option. This is not displayed to users.
If not given, defaults to the label. Can only be up to 100 characters.
description: Optional[:class:`str`]
An additional description of the option, if any.
Can only be up to 100 characters.
default: :class:`bool`
Whether this option is selected by default.
Raises
------
ValueError
The number of options exceeds 10.
"""
option = RadioGroupOption(
label=label,
value=value,
description=description,
default=default,
)
return self.append_option(option)
def append_option(self, option: RadioGroupOption) -> Self:
"""Appends an option to the radio group.
Parameters
----------
option: :class:`discord.RadioGroupOption`
The option to append to the radio group.
Raises
------
ValueError
The number of options exceeds 10.
"""
if len(self.underlying.options) >= 10:
raise ValueError("maximum number of options already provided")
self.underlying.options.append(option)
return self
def clear_options(self) -> Self:
"""Remove all options from the radio group."""
self.underlying.options.clear()
return self
def to_component_dict(self) -> RadioGroupComponentPayload:
return self.underlying.to_dict()
def refresh_state(self, data) -> None:
self._selected_value = data.get("value", None)
def refresh_from_modal(
self, interaction: Interaction, data: RadioGroupComponentPayload
) -> None:
return self.refresh_state(data)
@@ -0,0 +1,358 @@
"""
The MIT License (MIT)
Copyright (c) 2021-present Pycord Development
Permission is hereby granted, free of charge, to any person obtaining a
copy of this software and associated documentation files (the "Software"),
to deal in the Software without restriction, including without limitation
the rights to use, copy, modify, merge, publish, distribute, sublicense,
and/or sell copies of the Software, and to permit persons to whom the
Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
DEALINGS IN THE SOFTWARE.
"""
from __future__ import annotations
from functools import partial
from typing import TYPE_CHECKING, ClassVar, Iterator, TypeVar
from ..components import Section as SectionComponent
from ..components import _component_factory
from ..enums import ComponentType
from ..utils import find, get
from .button import Button
from .item import ItemCallbackType, ViewItem
from .text_display import TextDisplay
from .thumbnail import Thumbnail
__all__ = ("Section",)
if TYPE_CHECKING:
from typing_extensions import Self
from ..types.components import SectionComponent as SectionComponentPayload
from .view import DesignerView
S = TypeVar("S", bound="Section")
V = TypeVar("V", bound="DesignerView", covariant=True)
class Section(ViewItem[V]):
"""Represents a UI section. Sections must have 1-3 (inclusive) items and an accessory set.
.. versionadded:: 2.7
Parameters
----------
*items: :class:`ViewItem`
The initial items contained in this section, up to 3.
Currently only supports :class:`~discord.ui.TextDisplay`.
Sections must have at least 1 item before being sent.
accessory: Optional[:class:`ViewItem`]
The section's accessory. This is displayed in the top right of the section.
Currently only supports :class:`~discord.ui.Button` and :class:`~discord.ui.Thumbnail`.
Sections must have an accessory attached before being sent.
id: Optional[:class:`int`]
The section's ID.
Attributes
----------
items: List[:class:`ViewItem`]
The list of items in this section.
accessory: :class:`ViewItem`
The section's accessory, displayed in the top right of the section.
"""
__item_repr_attributes__: tuple[str, ...] = (
"items",
"accessory",
"id",
)
__section_accessory_item__: ClassVar[ItemCallbackType] = []
def __init_subclass__(cls) -> None:
accessory: list[ItemCallbackType] = []
for base in reversed(cls.__mro__):
for member in base.__dict__.values():
if hasattr(member, "__discord_ui_model_type__"):
accessory.append(member)
cls.__section_accessory_item__ = accessory
def __init__(
self, *items: ViewItem, accessory: ViewItem = None, id: int | None = None
):
super().__init__()
self.items: list[ViewItem] = []
self.accessory: ViewItem | None = None
self._underlying = self._generate_underlying(
id=id,
)
for func in self.__section_accessory_item__:
item: ViewItem = func.__discord_ui_model_type__(
**func.__discord_ui_model_kwargs__
)
item.callback = partial(func, self, item)
self.set_accessory(item)
setattr(self, func.__name__, item)
if accessory:
self.set_accessory(accessory)
for i in items:
self.add_item(i)
def _add_component_from_item(self, item: ViewItem):
self.underlying.components.append(item.underlying)
def _set_components(self, items: list[ViewItem]):
self.underlying.components.clear()
for item in items:
self._add_component_from_item(item)
def _generate_underlying(self, id: int | None = None) -> SectionComponent:
super()._generate_underlying(SectionComponent)
section = SectionComponent._raw_construct(
type=ComponentType.section,
id=id or self.id,
components=[],
accessory=None,
)
for i in self.items:
section.components.append(i._generate_underlying())
if self.accessory:
section.accessory = self.accessory._generate_underlying()
return section
def add_item(self, item: ViewItem) -> Self:
"""Adds an item to the section.
Parameters
----------
item: :class:`ViewItem`
The item to add to the section.
Raises
------
TypeError
An :class:`ViewItem` was not passed.
ValueError
Maximum number of items has been exceeded (3).
"""
if len(self.items) >= 3:
raise ValueError("maximum number of children exceeded")
if not isinstance(item, ViewItem):
raise TypeError(f"expected ViewItem not {item.__class__!r}")
item.parent = self
self.items.append(item)
self._add_component_from_item(item)
return self
def remove_item(self, item: ViewItem | str | int) -> Self:
"""Removes an item from the section. If an :class:`int` or :class:`str` is passed,
the item will be removed by Item ``id`` or ``custom_id`` respectively.
Parameters
----------
item: Union[:class:`ViewItem`, :class:`int`, :class:`str`]
The item, item ``id``, or item ``custom_id`` to remove from the section.
"""
if isinstance(item, (str, int)):
item = self.get_item(item)
try:
if item is self.accessory:
self.accessory = None
else:
self.items.remove(item)
item.parent = None
except ValueError:
pass
return self
def get_item(self, id: int | str) -> ViewItem | None:
"""Get an item from this section. Alias for `utils.get(section.walk_items(), ...)`.
If an ``int`` is provided, it will be retrieved by ``id``, otherwise it will check the accessory's ``custom_id``.
Parameters
----------
id: Union[:class:`str`, :class:`int`]
The id or custom_id of the item to get.
Returns
-------
Optional[:class:`ViewItem`]
The item with the matching ``id`` if it exists.
"""
if not id:
return None
attr = "id" if isinstance(id, int) else "custom_id"
if self.accessory and id == getattr(self.accessory, attr, None):
return self.accessory
child = find(lambda i: getattr(i, attr, None) == id, self.items)
return child
def add_text(self, content: str, *, id: int | None = None) -> Self:
"""Adds a :class:`TextDisplay` to the section.
Parameters
----------
content: :class:`str`
The content of the text display.
id: Optional[:class:`int`]
The text display's ID.
Raises
------
ValueError
Maximum number of items has been exceeded (3).
"""
if len(self.items) >= 3:
raise ValueError("maximum number of children exceeded")
text = TextDisplay(content, id=id)
return self.add_item(text)
def set_accessory(self, item: ViewItem) -> Self:
"""Set an item as the section's :attr:`accessory`.
Parameters
----------
item: :class:`ViewItem`
The item to set as accessory.
Currently only supports :class:`~discord.ui.Button` and :class:`~discord.ui.Thumbnail`.
Raises
------
TypeError
An :class:`ViewItem` was not passed.
"""
if not isinstance(item, ViewItem):
raise TypeError(f"expected ViewItem not {item.__class__!r}")
item.parent = self
self.accessory = item
self.underlying.accessory = item._generate_underlying()
return self
def set_thumbnail(
self,
url: str,
*,
description: str | None = None,
spoiler: bool = False,
id: int | None = None,
) -> Self:
"""Sets a :class:`Thumbnail` with the provided URL as the section's :attr:`accessory`.
Parameters
----------
url: :class:`str`
The url of the thumbnail.
description: Optional[:class:`str`]
The thumbnail's description, up to 1024 characters.
spoiler: Optional[:class:`bool`]
Whether the thumbnail has the spoiler overlay. Defaults to ``False``.
id: Optional[:class:`int`]
The thumbnail's ID.
"""
thumbnail = Thumbnail(url, description=description, spoiler=spoiler, id=id)
return self.set_accessory(thumbnail)
def copy_text(self) -> str:
"""Returns the text of all :class:`~discord.ui.TextDisplay` items in this section.
Equivalent to the `Copy Text` option on Discord clients.
"""
return "\n".join(t for i in self.items if (t := i.copy_text()))
def is_dispatchable(self) -> bool:
return self.accessory and self.accessory.is_dispatchable()
def is_persistent(self) -> bool:
if not isinstance(self.accessory, Button):
return True
return self.accessory.is_persistent()
def refresh_component(self, component: SectionComponent) -> None:
self.underlying = component
for x, y in zip(self.items, component.components):
x.refresh_component(y)
if self.accessory and component.accessory:
self.accessory.refresh_component(component.accessory)
def disable_all_items(self, *, exclusions: list[ViewItem] | None = None) -> Self:
"""
Disables all buttons and select menus in the section.
At the moment, this only disables :attr:`accessory` if it is a button.
Parameters
----------
exclusions: Optional[List[:class:`ViewItem`]]
A list of items in `self.items` to not disable from the view.
"""
for item in self.walk_items():
if hasattr(item, "disabled") and (
exclusions is None or item not in exclusions
):
item.disabled = True
return self
def enable_all_items(self, *, exclusions: list[ViewItem] | None = None) -> Self:
"""
Enables all buttons and select menus in the section.
At the moment, this only enables :attr:`accessory` if it is a button.
Parameters
----------
exclusions: Optional[List[:class:`ViewItem`]]
A list of items in `self.items` to not enable from the view.
"""
for item in self.walk_items():
if hasattr(item, "disabled") and (
exclusions is None or item not in exclusions
):
item.disabled = False
return self
def walk_items(self) -> Iterator[ViewItem]:
r = self.items
if self.accessory:
yield from r + [self.accessory]
else:
yield from r
def to_component_dict(self) -> SectionComponentPayload:
self._underlying = self._generate_underlying()
return super().to_component_dict()
@classmethod
def from_component(cls: type[S], component: SectionComponent) -> S:
from .view import _component_to_item
items = [_component_to_item(c) for c in component.components]
accessory = _component_to_item(component.accessory)
return cls(*items, accessory=accessory, id=component.id)
callback = None
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,122 @@
"""
The MIT License (MIT)
Copyright (c) 2021-present Pycord Development
Permission is hereby granted, free of charge, to any person obtaining a
copy of this software and associated documentation files (the "Software"),
to deal in the Software without restriction, including without limitation
the rights to use, copy, modify, merge, publish, distribute, sublicense,
and/or sell copies of the Software, and to permit persons to whom the
Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
DEALINGS IN THE SOFTWARE.
"""
from __future__ import annotations
from typing import TYPE_CHECKING, TypeVar
from ..components import Separator as SeparatorComponent
from ..components import _component_factory
from ..enums import ComponentType, SeparatorSpacingSize
from .item import ViewItem
__all__ = ("Separator",)
if TYPE_CHECKING:
from ..types.components import SeparatorComponent as SeparatorComponentPayload
from .view import DesignerView
S = TypeVar("S", bound="Separator")
V = TypeVar("V", bound="DesignerView", covariant=True)
class Separator(ViewItem[V]):
"""Represents a UI Separator.
.. versionadded:: 2.7
Parameters
----------
divider: :class:`bool`
Whether the separator is a divider. Defaults to ``True``.
spacing: :class:`~discord.SeparatorSpacingSize`
The spacing size of the separator. Defaults to :attr:`~discord.SeparatorSpacingSize.small`.
id: Optional[:class:`int`]
The separator's ID.
"""
__item_repr_attributes__: tuple[str, ...] = (
"divider",
"spacing",
"id",
)
def __init__(
self,
*,
divider: bool = True,
spacing: SeparatorSpacingSize = SeparatorSpacingSize.small,
id: int | None = None,
):
super().__init__()
self._underlying = self._generate_underlying(
id=id,
divider=divider,
spacing=spacing,
)
def _generate_underlying(
self,
divider: bool | None = None,
spacing: SeparatorSpacingSize | None = None,
id: int | None = None,
) -> SeparatorComponent:
super()._generate_underlying(SeparatorComponent)
return SeparatorComponent._raw_construct(
type=ComponentType.separator,
id=id or self.id,
divider=divider if divider is not None else self.divider,
spacing=spacing or self.spacing,
)
@property
def divider(self) -> bool:
"""Whether the separator is a divider. Defaults to ``True``."""
return self.underlying.divider
@divider.setter
def divider(self, value: bool) -> None:
self.underlying.divider = value
@property
def spacing(self) -> SeparatorSpacingSize:
"""The spacing size of the separator. Defaults to :attr:`~discord.SeparatorSpacingSize.small`."""
return self.underlying.spacing
@spacing.setter
def spacing(self, value: SeparatorSpacingSize) -> None:
self.underlying.spacing = value
def to_component_dict(self) -> SeparatorComponentPayload:
return super().to_component_dict()
@classmethod
def from_component(cls: type[S], component: SeparatorComponent) -> S:
return cls(
divider=component.divider, spacing=component.spacing, id=component.id
)
callback = None
@@ -0,0 +1,110 @@
"""
The MIT License (MIT)
Copyright (c) 2021-present Pycord Development
Permission is hereby granted, free of charge, to any person obtaining a
copy of this software and associated documentation files (the "Software"),
to deal in the Software without restriction, including without limitation
the rights to use, copy, modify, merge, publish, distribute, sublicense,
and/or sell copies of the Software, and to permit persons to whom the
Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
DEALINGS IN THE SOFTWARE.
"""
from __future__ import annotations
from typing import TYPE_CHECKING, TypeVar
from ..components import TextDisplay as TextDisplayComponent
from ..components import _component_factory
from ..enums import ComponentType
from .item import ModalItem, ViewItem
__all__ = ("TextDisplay",)
if TYPE_CHECKING:
from ..types.components import TextDisplayComponent as TextDisplayComponentPayload
from .core import ItemInterface
from .modal import DesignerModal
from .view import DesignerView
T = TypeVar("T", bound="TextDisplay")
V = TypeVar("V", bound="DesignerView", covariant=True)
M = TypeVar("M", bound="DesignerModal", covariant=True)
class TextDisplay(ViewItem[V], ModalItem[M]):
"""Represents a UI text display. A message can have up to 4000 characters across all :class:`TextDisplay` objects combined.
.. versionadded:: 2.7
Parameters
----------
content: :class:`str`
The text display's content, up to 4000 characters.
id: Optional[:class:`int`]
The text display's ID.
"""
__item_repr_attributes__: tuple[str, ...] = (
"content",
"id",
)
def __init__(
self,
content: str,
id: int | None = None,
):
super().__init__()
self._underlying = self._generate_underlying(
id=id,
content=content,
)
def _generate_underlying(
self,
content: str | None = None,
id: int | None = None,
) -> TextDisplayComponent:
super()._generate_underlying(TextDisplayComponent)
return TextDisplayComponent._raw_construct(
type=ComponentType.text_display,
id=id or self.id,
content=content or self.content,
)
@property
def content(self) -> str:
"""The text display's content."""
return self.underlying.content
@content.setter
def content(self, value: str) -> None:
self.underlying.content = value
def to_component_dict(self) -> TextDisplayComponentPayload:
return super().to_component_dict()
def copy_text(self) -> str:
"""Returns the content of this text display. Equivalent to the `Copy Text` option on Discord clients."""
return self.content
@classmethod
def from_component(cls: type[T], component: TextDisplayComponent) -> T:
return cls(component.content, id=component.id)
callback = None
@@ -0,0 +1,153 @@
"""
The MIT License (MIT)
Copyright (c) 2021-present Pycord Development
Permission is hereby granted, free of charge, to any person obtaining a
copy of this software and associated documentation files (the "Software"),
to deal in the Software without restriction, including without limitation
the rights to use, copy, modify, merge, publish, distribute, sublicense,
and/or sell copies of the Software, and to permit persons to whom the
Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
DEALINGS IN THE SOFTWARE.
"""
from __future__ import annotations
from typing import TYPE_CHECKING, TypeVar
from ..components import Thumbnail as ThumbnailComponent
from ..components import UnfurledMediaItem, _component_factory
from ..enums import ComponentType
from .item import ViewItem
__all__ = ("Thumbnail",)
if TYPE_CHECKING:
from ..types.components import ThumbnailComponent as ThumbnailComponentPayload
from .view import DesignerView
T = TypeVar("T", bound="Thumbnail")
V = TypeVar("V", bound="DesignerView", covariant=True)
class Thumbnail(ViewItem[V]):
"""Represents a UI Thumbnail.
.. versionadded:: 2.7
Parameters
----------
url: :class:`str`
The url of the thumbnail. This can either be an arbitrary URL or an ``attachment://`` URL to work with local files.
description: Optional[:class:`str`]
The thumbnail's description, up to 1024 characters.
spoiler: Optional[:class:`bool`]
Whether the thumbnail has the spoiler overlay. Defaults to ``False``.
id: Optional[:class:`int`]
The thumbnail's ID.
"""
__item_repr_attributes__: tuple[str, ...] = (
"url",
"description",
"spoiler",
"id",
)
def __init__(
self,
url: str,
*,
description: str = None,
spoiler: bool = False,
id: int = None,
):
super().__init__()
media = UnfurledMediaItem(url)
self._underlying = self._generate_underlying(
id=id,
media=media,
description=description,
spoiler=spoiler,
)
def _generate_underlying(
self,
media: UnfurledMediaItem | None = None,
description: str | None = None,
spoiler: bool | None = False,
id: int | None = None,
) -> ThumbnailComponent:
super()._generate_underlying(ThumbnailComponent)
return ThumbnailComponent._raw_construct(
type=ComponentType.thumbnail,
id=id or self.id,
media=media or self.media,
description=description or self.description,
spoiler=spoiler if spoiler is not None else self.spoiler,
)
@property
def media(self) -> UnfurledMediaItem:
"""The thumbnail's unerlying media item."""
return self.underlying.media
@media.setter
def media(self, value: UnfurledMediaItem) -> None:
self.underlying.media = value
@property
def url(self) -> str:
"""The URL of this thumbnail's media. This can either be an arbitrary URL or an ``attachment://`` URL."""
return self.underlying.media and self.underlying.media.url
@url.setter
def url(self, value: str) -> None:
self.underlying.media.url = value
@property
def description(self) -> str | None:
"""The thumbnail's description, up to 1024 characters."""
return self.underlying.description
@description.setter
def description(self, description: str | None) -> None:
self.underlying.description = description
@property
def spoiler(self) -> bool:
"""Whether the thumbnail has the spoiler overlay. Defaults to ``False``."""
return self.underlying.spoiler
@spoiler.setter
def spoiler(self, spoiler: bool) -> None:
self.underlying.spoiler = spoiler
def to_component_dict(self) -> ThumbnailComponentPayload:
return super().to_component_dict()
@classmethod
def from_component(cls: type[T], component: ThumbnailComponent) -> T:
return cls(
component.media and component.media.url,
description=component.description,
spoiler=component.spoiler,
id=component.id,
)
callback = None
File diff suppressed because it is too large Load Diff