diff --git a/CHANGELOG.md b/CHANGELOG.md index 7d76398448..6f7a28343c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -69,6 +69,8 @@ These changes are available on the `master` branch, but have not yet been releas ([#2818](https://github.com/Pycord-Development/pycord/pull/2818)) - Added `Interaction.attachment_size_limit`. ([#2854](https://github.com/Pycord-Development/pycord/pull/2854)) +- Added support for string selects in modals. + ([#2858](https://github.com/Pycord-Development/pycord/pull/2858)) ### Fixed diff --git a/discord/components.py b/discord/components.py index 39576c9eea..2420db40d7 100644 --- a/discord/components.py +++ b/discord/components.py @@ -50,6 +50,7 @@ from .types.components import ContainerComponent as ContainerComponentPayload from .types.components import FileComponent as FileComponentPayload from .types.components import InputText as InputTextComponentPayload + from .types.components import LabelComponent as LabelComponentPayload from .types.components import MediaGalleryComponent as MediaGalleryComponentPayload from .types.components import MediaGalleryItem as MediaGalleryItemPayload from .types.components import SectionComponent as SectionComponentPayload @@ -76,6 +77,7 @@ "FileComponent", "Separator", "Container", + "Label", ) C = TypeVar("C", bound="Component") @@ -375,6 +377,10 @@ class SelectMenu(Component): Added support for :attr:`ComponentType.user_select`, :attr:`ComponentType.role_select`, :attr:`ComponentType.mentionable_select`, and :attr:`ComponentType.channel_select`. + .. versionchanged:: 2.7 + + Added the :attr:`required` attribute for use in modals. + Attributes ---------- type: :class:`ComponentType` @@ -399,6 +405,8 @@ class SelectMenu(Component): except for :attr:`ComponentType.channel_select`. disabled: :class:`bool` Whether the select is disabled or not. + required: Optional[:class:`bool`] + Whether the select is required or not. Only useable in modals. Defaults to ``False``. """ __slots__: tuple[str, ...] = ( @@ -409,6 +417,7 @@ class SelectMenu(Component): "options", "channel_types", "disabled", + "required", ) __repr_info__: ClassVar[tuple[str, ...]] = __slots__ @@ -428,6 +437,9 @@ def __init__(self, data: SelectMenuPayload): self.channel_types: list[ChannelType] = [ try_enum(ChannelType, ct) for ct in data.get("channel_types", []) ] + self.required: bool | None = data.get( + "required" + ) # Currently defaults to False, pending change def to_dict(self) -> SelectMenuPayload: payload: SelectMenuPayload = { @@ -445,6 +457,8 @@ def to_dict(self) -> SelectMenuPayload: payload["channel_types"] = [ct.value for ct in self.channel_types] if self.placeholder: payload["placeholder"] = self.placeholder + if self.required is not None: + payload["required"] = self.required return payload @@ -1037,6 +1051,55 @@ def walk_components(self) -> Iterator[Component]: yield c +class Label(Component): + """Represents a Label used in modals as the top-level component. + + This is a component that holda another component alongside additional text in modals. + ``component`` may only be: + + - :class:`InputText` + - :class:`SelectMenu` (string) + + This inherits from :class:`Component`. + + .. versionadded:: 2.7 + + Attributes + ---------- + component: :class:`Component` + The component contained in this label. Currently supports :class:`InputText` and :class:`SelectMenu`. + label: :class:`str` + The main text associated with this label's ``component``, up to 45 characters. + description: Optional[:class:`str`] + The description associated with this label's ``component``, up to 100 characters. + """ + + __slots__: tuple[str, ...] = ("component", "label", "description") + + __repr_info__: ClassVar[tuple[str, ...]] = __slots__ + versions: tuple[int, ...] = () + + def __init__(self, data: LabelComponentPayload): + self.type: ComponentType = try_enum(ComponentType, data["type"]) + self.id: int = data["id"] + self.component: Component = _component_factory(data["component"]) + self.label: str = data["label"] + self.description: str | None = data.get("description") + + def to_dict(self) -> LabelComponentPayload: + payload = { + "type": int(self.type), + "id": self.id, + "component": self.component.to_dict(), + "label": self.label, + "description": self.description, + } + return payload + + def walk_components(self) -> Iterator[Component]: + yield from [self.component] + + COMPONENT_MAPPINGS = { 1: ActionRow, 2: Button, @@ -1053,6 +1116,7 @@ def walk_components(self) -> Iterator[Component]: 13: FileComponent, 14: Separator, 17: Container, + 18: Label, } STATE_COMPONENTS = (Section, Container, Thumbnail, MediaGallery, FileComponent) diff --git a/discord/interactions.py b/discord/interactions.py index 888ed7658f..5b29cdb336 100644 --- a/discord/interactions.py +++ b/discord/interactions.py @@ -193,6 +193,7 @@ class Interaction: "view", "modal", "attachment_size_limit", + "_raw_data", "_channel_data", "_message_data", "_guild_data", @@ -215,6 +216,7 @@ def __init__(self, *, data: InteractionPayload, state: ConnectionState): self._from_data(data) def _from_data(self, data: InteractionPayload): + self._raw_data: InteractionPayload = data self.id: int = int(data["id"]) self.type: InteractionType = try_enum(InteractionType, data["type"]) self.data: InteractionData | None = data.get("data") diff --git a/discord/types/components.py b/discord/types/components.py index 16d9661b3a..14063cdf54 100644 --- a/discord/types/components.py +++ b/discord/types/components.py @@ -33,7 +33,7 @@ from .emoji import PartialEmoji from .snowflake import Snowflake -ComponentType = Literal[1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 16, 17] +ComponentType = Literal[1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 16, 17, 18] ButtonStyle = Literal[1, 2, 3, 4, 5, 6] InputTextStyle = Literal[1, 2] SeparatorSpacingSize = Literal[1, 2] @@ -69,7 +69,7 @@ class InputText(BaseComponent): type: Literal[4] style: InputTextStyle custom_id: str - label: str + label: NotRequired[str] class SelectOption(TypedDict): @@ -89,6 +89,7 @@ class SelectMenu(BaseComponent): options: NotRequired[list[SelectOption]] type: Literal[3, 5, 6, 7, 8] custom_id: str + required: NotRequired[bool] class TextDisplayComponent(BaseComponent): @@ -151,6 +152,13 @@ class ContainerComponent(BaseComponent): components: list[AllowedContainerComponents] +class LabelComponent(BaseComponent): + type: Literal[18] + label: str + description: NotRequired[str] + component: SelectMenu | InputText + + Component = Union[ActionRow, ButtonComponent, SelectMenu, InputText] diff --git a/discord/ui/input_text.py b/discord/ui/input_text.py index fa9d7615ab..178d18b225 100644 --- a/discord/ui/input_text.py +++ b/discord/ui/input_text.py @@ -26,6 +26,11 @@ class InputText: label: :class:`str` The label for the input text field. Must be 45 characters or fewer. + description: Optional[:class:`str`] + The description for the input text field. + Must be 100 characters or fewer. + + .. versionadded:: 2.7 placeholder: Optional[:class:`str`] The placeholder text that is shown if nothing is selected, if any. Must be 100 characters or fewer. @@ -58,6 +63,7 @@ class InputText: "max_length", "custom_id", "id", + "description", ) def __init__( @@ -73,10 +79,13 @@ def __init__( value: str | None = None, row: int | None = None, id: int | None = None, + description: str | None = None, ): super().__init__() if len(str(label)) > 45: raise ValueError("label must be 45 characters or fewer") + if description and len(description) > 100: + raise ValueError("description must be 100 characters or fewer") if min_length and (min_length < 0 or min_length > 4000): raise ValueError("min_length must be between 0 and 4000") if max_length and (max_length < 0 or max_length > 4000): @@ -90,6 +99,7 @@ def __init__( f"expected custom_id to be str, not {custom_id.__class__.__name__}" ) custom_id = os.urandom(16).hex() if custom_id is None else custom_id + self.description: str | None = description self._underlying = InputTextComponent._raw_construct( type=ComponentType.input_text, @@ -236,3 +246,6 @@ def to_component_dict(self) -> InputTextComponentPayload: def refresh_state(self, data) -> None: self._input_value = data["value"] + + def uses_label(self) -> bool: + return self.description is not None diff --git a/discord/ui/item.py b/discord/ui/item.py index 7c324b0b2b..5b57a19d0f 100644 --- a/discord/ui/item.py +++ b/discord/ui/item.py @@ -107,6 +107,9 @@ def is_storable(self) -> bool: def is_persistent(self) -> bool: return not self.is_dispatchable() or self._provided_custom_id + def uses_label(self) -> bool: + return False + def copy_text(self) -> str: return "" diff --git a/discord/ui/modal.py b/discord/ui/modal.py index 58cf7db1e6..0e10e3e028 100644 --- a/discord/ui/modal.py +++ b/discord/ui/modal.py @@ -8,7 +8,9 @@ from itertools import groupby from typing import TYPE_CHECKING, Any, Callable +from ..enums import ComponentType from .input_text import InputText +from .select import Select __all__ = ( "Modal", @@ -30,10 +32,14 @@ class Modal: .. versionadded:: 2.0 + .. versionchanged:: 2.7 + + :attr:`discord.ComponentType.string_select` can now be used in modals. + Parameters ---------- - children: :class:`InputText` - The initial InputText fields that are displayed in the modal dialog. + children: Union[:class:`InputText`, :class:`Select`] + The initial InputText or Select components that are displayed in the modal dialog. title: :class:`str` The title of the modal dialog. Must be 45 characters or fewer. @@ -53,7 +59,7 @@ class Modal: def __init__( self, - *children: InputText, + *children: InputText | Select, title: str, custom_id: str | None = None, timeout: float | None = None, @@ -67,7 +73,7 @@ def __init__( if len(title) > 45: raise ValueError("title must be 45 characters or fewer") self._title = title - self._children: list[InputText] = list(children) + self._children: list[InputText | Select] = list(children) self._weights = _ModalWeights(self._children) loop = asyncio.get_running_loop() self._stopped: asyncio.Future[bool] = loop.create_future() @@ -138,18 +144,23 @@ def title(self, value: str): self._title = value @property - def children(self) -> list[InputText]: + def children(self) -> list[InputText | Select]: """The child components associated with the modal dialog.""" return self._children @children.setter - def children(self, value: list[InputText]): + def children(self, value: list[InputText | Select]): for item in value: - if not isinstance(item, InputText): + if not isinstance(item, (InputText, Select)): raise TypeError( - "all Modal children must be InputText, not" + "all Modal children must be InputText or Select, not" f" {item.__class__.__name__}" ) + elif ( + isinstance(item, Select) + and item.type is not ComponentType.string_select + ): + raise TypeError("only string selects may be added to modals") self._weights = _ModalWeights(self._children) self._children = value @@ -182,50 +193,72 @@ async def callback(self, interaction: Interaction): self.stop() def to_components(self) -> list[dict[str, Any]]: - def key(item: InputText) -> int: + def key(item: InputText | Select) -> 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] + labels = False + children = [] + for item in group: + if item.uses_label() or isinstance(item, Select): + labels = True + children.append(item) if not children: continue - components.append( - { - "type": 1, - "components": children, - } - ) + if labels: + for item in children: + component = item.to_component_dict() + label = component.pop("label", item.label) + components.append( + { + "type": 18, + "component": component, + "label": label, + "description": item.description, + } + ) + else: + components.append( + { + "type": 1, + "components": [item.to_component_dict() for item in children], + } + ) return components - def add_item(self, item: InputText) -> Self: - """Adds an InputText component to the modal dialog. + def add_item(self, item: InputText | Select) -> Self: + """Adds an InputText or Select component to the modal dialog. Parameters ---------- - item: :class:`InputText` + item: Union[:class:`InputText`, :class:`Select`] The item to add to the modal dialog """ if len(self._children) > 5: raise ValueError("You can only have up to 5 items in a modal dialog.") - if not isinstance(item, InputText): - raise TypeError(f"expected InputText not {item.__class__!r}") + if not isinstance(item, (InputText, Select)): + raise TypeError(f"expected InputText or Select, not {item.__class__!r}") + if isinstance(item, Select) and item.type is not ComponentType.string_select: + raise TypeError("only string selects may be added to modals") + if not item.label: + raise ValueError("Item must have a label set") self._weights.add_item(item) self._children.append(item) return self - def remove_item(self, item: InputText) -> Self: - """Removes an InputText component from the modal dialog. + def remove_item(self, item: InputText | Select) -> Self: + """Removes an InputText or Select component from the modal dialog. Parameters ---------- - item: :class:`InputText` + item: Union[:class:`InputText`, :class:`Select`] The item to remove from the modal dialog. """ try: @@ -280,7 +313,7 @@ async def on_timeout(self) -> None: class _ModalWeights: __slots__ = ("weights",) - def __init__(self, children: list[InputText]): + def __init__(self, children: list[InputText | Select]): self.weights: list[int] = [0, 0, 0, 0, 0] key = lambda i: sys.maxsize if i.row is None else i.row @@ -289,14 +322,14 @@ def __init__(self, children: list[InputText]): for item in group: self.add_item(item) - def find_open_space(self, item: InputText) -> int: + def find_open_space(self, item: InputText | Select) -> 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: + def add_item(self, item: InputText | Select) -> None: if item.row is not None: total = self.weights[item.row] + item.width if total > 5: @@ -310,7 +343,7 @@ def add_item(self, item: InputText) -> None: self.weights[index] += item.width item._rendered_row = index - def remove_item(self, item: InputText) -> None: + def remove_item(self, item: InputText | Select) -> None: if item._rendered_row is not None: self.weights[item._rendered_row] -= item.width item._rendered_row = None @@ -344,7 +377,14 @@ async def dispatch(self, user_id: int, custom_id: str, interaction: Interaction) components = [ component for parent_component in interaction.data["components"] - for component in parent_component["components"] + for component in ( + parent_component.get("components") + or ( + [parent_component.get("component")] + if parent_component.get("component") + else [] + ) + ) ] for component in components: for child in value.children: diff --git a/discord/ui/select.py b/discord/ui/select.py index 7c2f8b1f4a..42009c612e 100644 --- a/discord/ui/select.py +++ b/discord/ui/select.py @@ -80,6 +80,10 @@ class Select(Item[V]): :attr:`discord.ComponentType.role_select`, :attr:`discord.ComponentType.mentionable_select`, and :attr:`discord.ComponentType.channel_select`. + .. versionchanged:: 2.7 + + :attr:`discord.ComponentType.string_select` can now be sent in :class:`discord.ui.Modal`. + Parameters ---------- select_type: :class:`discord.ComponentType` @@ -114,6 +118,20 @@ class Select(Item[V]): ordering. The row number must be between 0 and 4 (i.e. zero indexed). id: Optional[:class:`int`] The select menu's ID. + label: Optional[:class:`str`] + The label for the select menu. Only useable in modals. + Must be 45 characters or fewer. + + .. versionadded:: 2.7 + description: Optional[:class:`str`] + The description for the select menu. Only useable in modals. + Must be 100 characters or fewer. + + .. versionadded:: 2.7 + required: Optional[:class:`bool`] + Whether the select is required or not. Only useable in modals. Defaults to ``True`` in modals. + + .. versionadded:: 2.7 """ __item_repr_attributes__: tuple[str, ...] = ( @@ -126,6 +144,9 @@ class Select(Item[V]): "disabled", "custom_id", "id", + "label", + "description", + "required", ) def __init__( @@ -141,13 +162,28 @@ def __init__( disabled: bool = False, row: int | None = None, id: int | None = None, + label: str | None = None, + description: str | None = None, + required: str | None = None, ) -> None: if options and select_type is not ComponentType.string_select: raise InvalidArgument("options parameter is only valid for string selects") + if ( + label or description or required + ) and select_type is not ComponentType.string_select: + raise InvalidArgument( + "label, description and required parameters are only valid for selects in modals" + ) + if label and len(label) > 45: + raise ValueError("label must be 45 characters or fewer") + if description and len(description) > 100: + raise ValueError("description must be 100 characters or fewer") if channel_types and select_type is not ComponentType.channel_select: raise InvalidArgument( "channel_types parameter is only valid for channel selects" ) + if required and min_values < 1: + raise ValueError("min_values must be greater than 0 when required=True") super().__init__() self._selected_values: list[str] = [] self._interaction: Interaction | None = None @@ -162,6 +198,9 @@ def __init__( f"expected custom_id to be str, not {custom_id.__class__.__name__}" ) + self.label: str | None = label + self.description: str | None = description + self._provided_custom_id = custom_id is not None custom_id = os.urandom(16).hex() if custom_id is None else custom_id self._underlying: SelectMenu = SelectMenu._raw_construct( @@ -174,6 +213,7 @@ def __init__( options=options or [], channel_types=channel_types or [], id=id, + required=required, ) self.row = row @@ -232,6 +272,15 @@ def disabled(self) -> bool: """Whether the select is disabled or not.""" return self._underlying.disabled + @property + def required(self) -> bool: + """Whether the select is required or not. Only applicable in modal selects.""" + return self._underlying.required + + @required.setter + def required(self, value: bool): + self._underlying.required = value + @disabled.setter def disabled(self, value: bool): self._underlying.disabled = bool(value) @@ -420,8 +469,10 @@ def to_component_dict(self) -> SelectMenuPayload: def refresh_component(self, component: SelectMenu) -> None: self._underlying = component - def refresh_state(self, interaction: Interaction) -> None: - data: ComponentInteractionData = interaction.data # type: ignore + def refresh_state(self, interaction: Interaction | dict) -> None: + data: ComponentInteractionData = ( + interaction.data if isinstance(interaction, Interaction) else interaction + ) self._selected_values = data.get("values", []) self._interaction = interaction @@ -438,6 +489,7 @@ def from_component(cls: type[S], component: SelectMenu) -> S: disabled=component.disabled, row=None, id=component.id, + required=component.required, ) @property @@ -450,6 +502,9 @@ def is_dispatchable(self) -> bool: def is_storable(self) -> bool: return True + def uses_label(self) -> bool: + return bool(self.label or self.description or (self.required is not None)) + _select_types = ( ComponentType.string_select, diff --git a/discord/ui/view.py b/discord/ui/view.py index 64b0520172..43dd9cccba 100644 --- a/discord/ui/view.py +++ b/discord/ui/view.py @@ -38,6 +38,7 @@ from ..components import Component from ..components import Container as ContainerComponent from ..components import FileComponent +from ..components import Label as LabelComponent from ..components import MediaGallery as MediaGalleryComponent from ..components import Section as SectionComponent from ..components import SelectMenu as SelectComponent @@ -120,6 +121,11 @@ def _component_to_item(component: Component) -> Item[V]: # Handle ActionRow.children manually, or design ui.ActionRow? return component + if isinstance(component, LabelComponent): + ret = _component_to_item(component.component) + ret.label = component.label + ret.description = component.description + return ret return Item.from_component(component) @@ -392,6 +398,11 @@ def add_item(self, item: Item[V]) -> None: if not isinstance(item, Item): raise TypeError(f"expected Item not {item.__class__!r}") + if item.uses_label(): + raise ValueError( + f"cannot use label, description or required on select menus in views." + ) + self.__weights.add_item(item) item.parent = self