Browse Source

Add support for label components and select in modals

pull/10262/head
Rapptz 5 days ago
parent
commit
3fb627d078
  1. 70
      discord/components.py
  2. 1
      discord/enums.py
  3. 14
      discord/types/components.py
  4. 17
      discord/types/interactions.py
  5. 1
      discord/ui/__init__.py
  6. 140
      discord/ui/label.py
  7. 27
      discord/ui/modal.py
  8. 20
      discord/ui/select.py
  9. 18
      discord/ui/text_input.py
  10. 5
      discord/ui/view.py
  11. 26
      docs/interactions/api.rst

70
discord/components.py

@ -70,6 +70,7 @@ if TYPE_CHECKING:
ThumbnailComponent as ThumbnailComponentPayload, ThumbnailComponent as ThumbnailComponentPayload,
ContainerComponent as ContainerComponentPayload, ContainerComponent as ContainerComponentPayload,
UnfurledMediaItem as UnfurledMediaItemPayload, UnfurledMediaItem as UnfurledMediaItemPayload,
LabelComponent as LabelComponentPayload,
) )
from .emoji import Emoji from .emoji import Emoji
@ -109,6 +110,7 @@ __all__ = (
'Container', 'Container',
'TextDisplay', 'TextDisplay',
'SeparatorComponent', 'SeparatorComponent',
'LabelComponent',
) )
@ -348,6 +350,10 @@ class SelectMenu(Component):
id: Optional[:class:`int`] id: Optional[:class:`int`]
The ID of this component. The ID of this component.
.. versionadded:: 2.6
required: :class:`bool`
Whether the select is required. Only applicable within modals.
.. versionadded:: 2.6 .. versionadded:: 2.6
""" """
@ -361,6 +367,7 @@ class SelectMenu(Component):
'disabled', 'disabled',
'channel_types', 'channel_types',
'default_values', 'default_values',
'required',
'id', 'id',
) )
@ -372,6 +379,7 @@ class SelectMenu(Component):
self.placeholder: Optional[str] = data.get('placeholder') self.placeholder: Optional[str] = data.get('placeholder')
self.min_values: int = data.get('min_values', 1) self.min_values: int = data.get('min_values', 1)
self.max_values: int = data.get('max_values', 1) self.max_values: int = data.get('max_values', 1)
self.required: bool = data.get('required', False)
self.options: List[SelectOption] = [SelectOption.from_dict(option) for option in data.get('options', [])] self.options: List[SelectOption] = [SelectOption.from_dict(option) for option in data.get('options', [])]
self.disabled: bool = data.get('disabled', False) self.disabled: bool = data.get('disabled', False)
self.channel_types: List[ChannelType] = [try_enum(ChannelType, t) for t in data.get('channel_types', [])] self.channel_types: List[ChannelType] = [try_enum(ChannelType, t) for t in data.get('channel_types', [])]
@ -544,7 +552,7 @@ class TextInput(Component):
------------ ------------
custom_id: Optional[:class:`str`] custom_id: Optional[:class:`str`]
The ID of the text input that gets received during an interaction. The ID of the text input that gets received during an interaction.
label: :class:`str` label: Optional[:class:`str`]
The label to display above the text input. The label to display above the text input.
style: :class:`TextStyle` style: :class:`TextStyle`
The style of the text input. The style of the text input.
@ -580,7 +588,7 @@ class TextInput(Component):
def __init__(self, data: TextInputPayload, /) -> None: def __init__(self, data: TextInputPayload, /) -> None:
self.style: TextStyle = try_enum(TextStyle, data['style']) self.style: TextStyle = try_enum(TextStyle, data['style'])
self.label: str = data['label'] self.label: Optional[str] = data.get('label')
self.custom_id: str = data['custom_id'] self.custom_id: str = data['custom_id']
self.placeholder: Optional[str] = data.get('placeholder') self.placeholder: Optional[str] = data.get('placeholder')
self.value: Optional[str] = data.get('value') self.value: Optional[str] = data.get('value')
@ -1309,6 +1317,62 @@ class Container(Component):
return payload return payload
class LabelComponent(Component):
"""Represents a label component from the Discord Bot UI Kit.
This inherits from :class:`Component`.
.. note::
The user constructible and usable type for creating a label is
:class:`discord.ui.Label` not this one.
.. versionadded:: 2.6
Attributes
----------
label: :class:`str`
The label text to display.
description: Optional[:class:`str`]
The description text to display below the label, if any.
component: :class:`Component`
The component that this label is associated with.
id: Optional[:class:`int`]
The ID of this component.
"""
__slots__ = (
'label',
'description',
'commponent',
'id',
)
__repr_info__ = ('label', 'description', 'commponent', 'id,')
def __init__(self, data: LabelComponentPayload, state: Optional[ConnectionState]) -> None:
self.component: Component = _component_factory(data['component'], state) # type: ignore
self.label: str = data['label']
self.id: Optional[int] = data.get('id')
self.description: Optional[str] = data.get('description')
@property
def type(self) -> Literal[ComponentType.label]:
return ComponentType.label
def to_dict(self) -> LabelComponentPayload:
payload: LabelComponentPayload = {
'type': self.type.value,
'label': self.label,
'component': self.component.to_dict(), # type: ignore
}
if self.description:
payload['description'] = self.description
if self.id is not None:
payload['id'] = self.id
return payload
def _component_factory(data: ComponentPayload, state: Optional[ConnectionState] = None) -> Optional[Component]: def _component_factory(data: ComponentPayload, state: Optional[ConnectionState] = None) -> Optional[Component]:
if data['type'] == 1: if data['type'] == 1:
return ActionRow(data) return ActionRow(data)
@ -1332,3 +1396,5 @@ def _component_factory(data: ComponentPayload, state: Optional[ConnectionState]
return SeparatorComponent(data) return SeparatorComponent(data)
elif data['type'] == 17: elif data['type'] == 17:
return Container(data, state) return Container(data, state)
elif data['type'] == 18:
return LabelComponent(data, state)

1
discord/enums.py

@ -677,6 +677,7 @@ class ComponentType(Enum):
file = 13 file = 13
separator = 14 separator = 14
container = 17 container = 17
label = 18
def __int__(self) -> int: def __int__(self) -> int:
return self.value return self.value

14
discord/types/components.py

@ -30,7 +30,7 @@ from typing_extensions import NotRequired
from .emoji import PartialEmoji from .emoji import PartialEmoji
from .channel import ChannelType from .channel import ChannelType
ComponentType = Literal[1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 17] ComponentType = Literal[1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 17, 18]
ButtonStyle = Literal[1, 2, 3, 4, 5, 6] ButtonStyle = Literal[1, 2, 3, 4, 5, 6]
TextStyle = Literal[1, 2] TextStyle = Literal[1, 2]
DefaultValueType = Literal['user', 'role', 'channel'] DefaultValueType = Literal['user', 'role', 'channel']
@ -110,7 +110,7 @@ class TextInput(ComponentBase):
type: Literal[4] type: Literal[4]
custom_id: str custom_id: str
style: TextStyle style: TextStyle
label: str label: Optional[str]
placeholder: NotRequired[str] placeholder: NotRequired[str]
value: NotRequired[str] value: NotRequired[str]
required: NotRequired[bool] required: NotRequired[bool]
@ -120,6 +120,7 @@ class TextInput(ComponentBase):
class SelectMenu(SelectComponent): class SelectMenu(SelectComponent):
type: Literal[3, 5, 6, 7, 8] type: Literal[3, 5, 6, 7, 8]
required: NotRequired[bool] # Only for StringSelect within modals
options: NotRequired[List[SelectOption]] options: NotRequired[List[SelectOption]]
channel_types: NotRequired[List[ChannelType]] channel_types: NotRequired[List[ChannelType]]
default_values: NotRequired[List[SelectDefaultValues]] default_values: NotRequired[List[SelectDefaultValues]]
@ -187,6 +188,13 @@ class ContainerComponent(ComponentBase):
components: List[ContainerChildComponent] components: List[ContainerChildComponent]
class LabelComponent(ComponentBase):
type: Literal[18]
label: str
description: NotRequired[str]
component: Union[StringSelectComponent, TextInput]
ActionRowChildComponent = Union[ButtonComponent, SelectMenu, TextInput] ActionRowChildComponent = Union[ButtonComponent, SelectMenu, TextInput]
ContainerChildComponent = Union[ ContainerChildComponent = Union[
ActionRow, ActionRow,
@ -199,4 +207,4 @@ ContainerChildComponent = Union[
SeparatorComponent, SeparatorComponent,
ThumbnailComponent, ThumbnailComponent,
] ]
Component = Union[ActionRowChildComponent, ContainerChildComponent] Component = Union[ActionRowChildComponent, LabelComponent, ContainerChildComponent]

17
discord/types/interactions.py

@ -209,7 +209,13 @@ class ModalSubmitTextInputInteractionData(TypedDict):
value: str value: str
ModalSubmitComponentItemInteractionData = ModalSubmitTextInputInteractionData class ModalSubmitStringSelectInteractionData(TypedDict):
type: Literal[3]
custom_id: str
values: List[str]
ModalSubmitComponentItemInteractionData = Union[ModalSubmitTextInputInteractionData, ModalSubmitStringSelectInteractionData]
class ModalSubmitActionRowInteractionData(TypedDict): class ModalSubmitActionRowInteractionData(TypedDict):
@ -217,7 +223,14 @@ class ModalSubmitActionRowInteractionData(TypedDict):
components: List[ModalSubmitComponentItemInteractionData] components: List[ModalSubmitComponentItemInteractionData]
ModalSubmitComponentInteractionData = Union[ModalSubmitActionRowInteractionData, ModalSubmitComponentItemInteractionData] class ModalSubmitLabelInteractionData(TypedDict):
type: Literal[18]
component: ModalSubmitComponentItemInteractionData
ModalSubmitComponentInteractionData = Union[
ModalSubmitLabelInteractionData, ModalSubmitActionRowInteractionData, ModalSubmitComponentItemInteractionData
]
class ModalSubmitInteractionData(TypedDict): class ModalSubmitInteractionData(TypedDict):

1
discord/ui/__init__.py

@ -24,3 +24,4 @@ from .separator import *
from .text_display import * from .text_display import *
from .thumbnail import * from .thumbnail import *
from .action_row import * from .action_row import *
from .label import *

140
discord/ui/label.py

@ -0,0 +1,140 @@
"""
The MIT License (MIT)
Copyright (c) 2015-present Rapptz
Permission is hereby granted, free of charge, to any person obtaining a
copy of this software and associated documentation files (the "Software"),
to deal in the Software without restriction, including without limitation
the rights to use, copy, modify, merge, publish, distribute, sublicense,
and/or sell copies of the Software, and to permit persons to whom the
Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
DEALINGS IN THE SOFTWARE.
"""
from __future__ import annotations
from typing import TYPE_CHECKING, Generator, Literal, Optional, Tuple, TypeVar
from ..components import LabelComponent
from ..enums import ComponentType
from ..utils import MISSING
from .item import Item
if TYPE_CHECKING:
from typing_extensions import Self
from ..types.components import LabelComponent as LabelComponentPayload
from .view import View
# fmt: off
__all__ = (
'Label',
)
# fmt: on
V = TypeVar('V', bound='View', covariant=True)
class Label(Item[V]):
"""Represents a UI label within a modal.
.. versionadded:: 2.6
Parameters
------------
text: :class:`str`
The text to display above the input field.
Can only be up to 45 characters.
description: Optional[:class:`str`]
The description text to display right below the label text.
Can only be up to 100 characters.
component: Union[:class:`discord.ui.TextInput`, :class:`discord.ui.Select`]
The component to display below the label.
id: Optional[:class:`int`]
The ID of the component. This must be unique across the view.
Attributes
------------
text: :class:`str`
The text to display above the input field.
Can only be up to 45 characters.
description: Optional[:class:`str`]
The description text to display right below the label text.
Can only be up to 100 characters.
component: :class:`Item`
The component to display below the label. Currently only
supports :class:`TextInput` and :class:`Select`.
"""
__item_repr_attributes__: Tuple[str, ...] = (
'text',
'description',
'component',
)
def __init__(
self,
*,
text: str,
component: Item[V],
description: Optional[str] = None,
id: Optional[int] = None,
) -> None:
super().__init__()
self.component: Item[V] = component
self.text: str = text
self.description: Optional[str] = description
self.id = id
@property
def width(self) -> int:
return 5
def _has_children(self) -> bool:
return True
def walk_children(self) -> Generator[Item[V], None, None]:
yield self.component
def to_component_dict(self) -> LabelComponentPayload:
payload: LabelComponentPayload = {
'type': ComponentType.label.value,
'label': self.text,
'component': self.component.to_component_dict(), # type: ignore
}
if self.description:
payload['description'] = self.description
if self.id is not None:
payload['id'] = self.id
return payload
@classmethod
def from_component(cls, component: LabelComponent) -> Self:
from .view import _component_to_item
self = cls(
text=component.label,
component=MISSING,
description=component.description,
)
self.component = _component_to_item(component.component, self)
return self
@property
def type(self) -> Literal[ComponentType.label]:
return ComponentType.label
def is_dispatchable(self) -> bool:
return False

27
discord/ui/modal.py

@ -34,6 +34,7 @@ from ..utils import MISSING, find
from .._types import ClientT from .._types import ClientT
from .item import Item from .item import Item
from .view import View from .view import View
from .label import Label
if TYPE_CHECKING: if TYPE_CHECKING:
from typing_extensions import Self from typing_extensions import Self
@ -170,8 +171,10 @@ class Modal(View):
for component in components: for component in components:
if component['type'] == 1: if component['type'] == 1:
self._refresh(interaction, component['components']) self._refresh(interaction, component['components'])
elif component['type'] == 18:
self._refresh(interaction, [component['component']])
else: else:
item = find(lambda i: i.custom_id == component['custom_id'], self._children) # type: ignore item = find(lambda i: getattr(i, 'custom_id', None) == component['custom_id'], self.walk_children()) # type: ignore
if item is None: if item is None:
_log.debug("Modal interaction referencing unknown item custom_id %s. Discarding", component['custom_id']) _log.debug("Modal interaction referencing unknown item custom_id %s. Discarding", component['custom_id'])
continue continue
@ -194,6 +197,28 @@ class Modal(View):
# In the future, maybe this will require checking if we set an error response. # In the future, maybe this will require checking if we set an error response.
self.stop() self.stop()
def to_components(self) -> List[Dict[str, Any]]:
def key(item: Item) -> int:
return item._rendered_row or 0
children = sorted(self._children, key=key)
components: List[Dict[str, Any]] = []
for child in children:
if isinstance(child, Label):
components.append(child.to_component_dict()) # type: ignore
else:
# Every implicit child wrapped in an ActionRow in a modal
# has a single child of width 5
# It's also deprecated to use ActionRow in modals
components.append(
{
'type': 1,
'components': [child.to_component_dict()],
}
)
return components
def _dispatch_submit( def _dispatch_submit(
self, interaction: Interaction, components: List[ModalSubmitComponentInteractionDataPayload] self, interaction: Interaction, components: List[ModalSubmitComponentInteractionDataPayload]
) -> None: ) -> None:

20
discord/ui/select.py

@ -239,6 +239,7 @@ class BaseSelect(Item[V]):
min_values: Optional[int] = None, min_values: Optional[int] = None,
max_values: Optional[int] = None, max_values: Optional[int] = None,
disabled: bool = False, disabled: bool = False,
required: bool = False,
options: List[SelectOption] = MISSING, options: List[SelectOption] = MISSING,
channel_types: List[ChannelType] = MISSING, channel_types: List[ChannelType] = MISSING,
default_values: Sequence[SelectDefaultValue] = MISSING, default_values: Sequence[SelectDefaultValue] = MISSING,
@ -257,6 +258,7 @@ class BaseSelect(Item[V]):
min_values=min_values, min_values=min_values,
max_values=max_values, max_values=max_values,
disabled=disabled, disabled=disabled,
required=required,
channel_types=[] if channel_types is MISSING else channel_types, channel_types=[] if channel_types is MISSING else channel_types,
options=[] if options is MISSING else options, options=[] if options is MISSING else options,
default_values=[] if default_values is MISSING else default_values, default_values=[] if default_values is MISSING else default_values,
@ -332,6 +334,18 @@ class BaseSelect(Item[V]):
def disabled(self, value: bool) -> None: def disabled(self, value: bool) -> None:
self._underlying.disabled = bool(value) self._underlying.disabled = bool(value)
@property
def required(self) -> bool:
""":class:`bool`: Whether the select is required or not. Only supported in modals.
.. versionadded:: 2.6
"""
return self._underlying.required
@required.setter
def required(self, value: bool) -> None:
self._underlying.required = bool(value)
@property @property
def width(self) -> int: def width(self) -> int:
return 5 return 5
@ -399,6 +413,10 @@ class Select(BaseSelect[V]):
Can only contain up to 25 items. Can only contain up to 25 items.
disabled: :class:`bool` disabled: :class:`bool`
Whether the select is disabled or not. Whether the select is disabled or not.
required: :class:`bool`
Whether the select is required. Only applicable within modals.
.. versionadded:: 2.6
row: Optional[:class:`int`] row: Optional[:class:`int`]
The relative row this select menu belongs to. A Discord component can only have 5 The relative row this select menu belongs to. A Discord component can only have 5
rows. By default, items are arranged automatically into those 5 rows. If you'd rows. By default, items are arranged automatically into those 5 rows. If you'd
@ -426,6 +444,7 @@ class Select(BaseSelect[V]):
max_values: int = 1, max_values: int = 1,
options: List[SelectOption] = MISSING, options: List[SelectOption] = MISSING,
disabled: bool = False, disabled: bool = False,
required: bool = True,
row: Optional[int] = None, row: Optional[int] = None,
id: Optional[int] = None, id: Optional[int] = None,
) -> None: ) -> None:
@ -436,6 +455,7 @@ class Select(BaseSelect[V]):
min_values=min_values, min_values=min_values,
max_values=max_values, max_values=max_values,
disabled=disabled, disabled=disabled,
required=required,
options=options, options=options,
row=row, row=row,
id=id, id=id,

18
discord/ui/text_input.py

@ -29,7 +29,7 @@ from typing import TYPE_CHECKING, Literal, Optional, Tuple, TypeVar
from ..components import TextInput as TextInputComponent from ..components import TextInput as TextInputComponent
from ..enums import ComponentType, TextStyle from ..enums import ComponentType, TextStyle
from ..utils import MISSING from ..utils import MISSING, deprecated
from .item import Item from .item import Item
if TYPE_CHECKING: if TYPE_CHECKING:
@ -63,9 +63,15 @@ class TextInput(Item[V]):
Parameters Parameters
------------ ------------
label: :class:`str` label: Optional[:class:`str`]
The label to display above the text input. The label to display above the text input.
Can only be up to 45 characters. Can only be up to 45 characters.
.. deprecated:: 2.6
This parameter is deprecated, use :class:`discord.ui.Label` instead.
.. versionchanged:: 2.6
This parameter is now optional and defaults to ``None``.
custom_id: :class:`str` custom_id: :class:`str`
The ID of the text input that gets received during an interaction. The ID of the text input that gets received during an interaction.
If not given then one is generated for you. If not given then one is generated for you.
@ -108,7 +114,7 @@ class TextInput(Item[V]):
def __init__( def __init__(
self, self,
*, *,
label: str, label: Optional[str] = None,
style: TextStyle = TextStyle.short, style: TextStyle = TextStyle.short,
custom_id: str = MISSING, custom_id: str = MISSING,
placeholder: Optional[str] = None, placeholder: Optional[str] = None,
@ -166,12 +172,14 @@ class TextInput(Item[V]):
return self._value or '' return self._value or ''
@property @property
def label(self) -> str: @deprecated('discord.ui.Label')
def label(self) -> Optional[str]:
""":class:`str`: The label of the text input.""" """:class:`str`: The label of the text input."""
return self._underlying.label return self._underlying.label
@label.setter @label.setter
def label(self, value: str) -> None: @deprecated('discord.ui.Label')
def label(self, value: Optional[str]) -> None:
self._underlying.label = value self._underlying.label = value
@property @property

5
discord/ui/view.py

@ -65,6 +65,7 @@ from ..components import (
SeparatorComponent, SeparatorComponent,
ThumbnailComponent, ThumbnailComponent,
Container as ContainerComponent, Container as ContainerComponent,
LabelComponent,
) )
from ..utils import get as _utils_get, find as _utils_find from ..utils import get as _utils_get, find as _utils_find
@ -147,6 +148,10 @@ def _component_to_item(component: Component, parent: Optional[Item] = None) -> I
from .container import Container from .container import Container
item = Container.from_component(component) item = Container.from_component(component)
elif isinstance(component, LabelComponent):
from .label import Label
item = Label.from_component(component)
else: else:
item = Item.from_component(component) item = Item.from_component(component)

26
docs/interactions/api.rst

@ -113,6 +113,15 @@ TextInput
:members: :members:
:inherited-members: :inherited-members:
LabelComponent
~~~~~~~~~~~~~~~~
.. attributetable:: LabelComponent
.. autoclass:: LabelComponent()
:members:
:inherited-members:
SectionComponent SectionComponent
~~~~~~~~~~~~~~~~ ~~~~~~~~~~~~~~~~
@ -425,7 +434,7 @@ Enumerations
.. attribute:: media_gallery .. attribute:: media_gallery
Represents a media gallery component. Represents a media gallery component.
.. versionadded:: 2.6 .. versionadded:: 2.6
.. attribute:: file .. attribute:: file
@ -446,6 +455,12 @@ Enumerations
.. versionadded:: 2.6 .. versionadded:: 2.6
.. attribute:: label
Represents a label container component, usually in a modal.
.. versionadded:: 2.6
.. class:: ButtonStyle .. class:: ButtonStyle
Represents the style of the button component. Represents the style of the button component.
@ -742,6 +757,15 @@ File
:members: :members:
:inherited-members: :inherited-members:
Label
~~~~~~
.. attributetable:: discord.ui.Label
.. autoclass:: discord.ui.Label
:members:
:inherited-members:
MediaGallery MediaGallery
~~~~~~~~~~~~ ~~~~~~~~~~~~

Loading…
Cancel
Save