From 2fcd3a970d7c87df6164ce39d9a367d503135bbe Mon Sep 17 00:00:00 2001 From: UK <41271523+NeloBlivion@users.noreply.github.com> Date: Wed, 3 Sep 2025 07:41:06 +0100 Subject: [PATCH 01/99] downgrade view --- discord/ui/view.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/discord/ui/view.py b/discord/ui/view.py index 64b0520172..2746ba0d1f 100644 --- a/discord/ui/view.py +++ b/discord/ui/view.py @@ -169,6 +169,9 @@ def remove_item(self, item: Item[V]) -> None: if item._rendered_row is not None: self.weights[item._rendered_row] -= item.width item._rendered_row = None + if len(self.weights) > 5: # attempt to downgrade view + if all(x == 0 for x in self.weights[5:]): + self.weights = self.weights[:5] def clear(self) -> None: self.weights = [0, 0, 0, 0, 0] From b319d13b5ecfba92032515b961273c55ca9178a8 Mon Sep 17 00:00:00 2001 From: UK <41271523+NeloBlivion@users.noreply.github.com> Date: Wed, 3 Sep 2025 14:00:58 +0100 Subject: [PATCH 02/99] skeleton for actionrow --- discord/ui/action_row.py | 285 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 285 insertions(+) create mode 100644 discord/ui/action_row.py diff --git a/discord/ui/action_row.py b/discord/ui/action_row.py new file mode 100644 index 0000000000..8c8a7f9330 --- /dev/null +++ b/discord/ui/action_row.py @@ -0,0 +1,285 @@ +from __future__ import annotations + +from functools import partial +from typing import TYPE_CHECKING, ClassVar, Iterator, TypeVar + +from ..colour import Colour +from ..components import ActionRow as ActionRowComponent +from ..components import Container as ContainerComponent +from ..components import _component_factory +from ..enums import ComponentType, SeparatorSpacingSize +from ..utils import find, get +from .file import File +from .item import Item, ItemCallbackType +from .media_gallery import MediaGallery +from .section import Section +from .separator import Separator +from .text_display import TextDisplay +from .view import _walk_all_components + +__all__ = ("Container",) + +if TYPE_CHECKING: + from typing_extensions import Self + + from ..types.components import ContainerComponent as ContainerComponentPayload + from .view import View + + +AC = TypeVar("A", bound="ActionRow") +V = TypeVar("V", bound="View", covariant=True) + + +class ActionRow(Item[V]): + """Represents a UI Action Row. + + The items supported are as follows: + + - :class:`discord.ui.Select` + - :class:`discord.ui.Button` (in views) + - :class:`discord.ui.InputText` (in modals) + + .. versionadded:: 2.7 + + Parameters + ---------- + *items: :class:`Item` + The initial items in this action row. + id: Optional[:class:`int`] + The action's ID. + """ + + __item_repr_attributes__: tuple[str, ...] = ( + "items", + "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: Item, + id: int | None = None, + ): + super().__init__() + + self.items: list[Item] = [] + + self._underlying = ActionRowComponent._raw_construct( + type=ComponentType.action_row, + id=id, + components=[], + ) + + for func in self.__row_children_items__: + item: Item = 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) + + def _add_component_from_item(self, item: Item): + self._underlying.components.append(item._underlying) + + def _set_components(self, items: list[Item]): + self._underlying.components.clear() + for item in items: + self._add_component_from_item(item) + + def add_item(self, item: Item) -> Self: + """Adds an item to the action row. + + Parameters + ---------- + item: :class:`Item` + The item to add to the action row. + + Raises + ------ + TypeError + An :class:`Item` was not passed. + """ + + if not isinstance(item, Item): + raise TypeError(f"expected Item not {item.__class__!r}") + + item._view = self.view + item.parent = self + + self.items.append(item) + self._add_component_from_item(item) + return self + + def remove_item(self, item: Item | 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:`Item`, :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.items.remove(item) + except ValueError: + pass + return self + + def get_item(self, id: str | int) -> Item | None: + """Get an item from this action row. Roughly equivalent to `utils.get(row.items, ...)`. + 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:`Item`] + 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) + return child + + def add_button( + self, + *, + accessory: Item, + 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 + ---------- + *items: :class:`Item` + The items contained in this section, up to 3. + Currently only supports :class:`~discord.ui.TextDisplay`. + accessory: Optional[:class:`Item`] + 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_select(self, url: str, spoiler: bool = False, id: int | None = None) -> Self: + """Adds a :class:`TextDisplay` 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) + + @Item.view.setter + def view(self, value): + self._view = value + for item in self.items: + item.parent = self + item._view = value + + @property + def type(self) -> ComponentType: + return self._underlying.type + + @property + def width(self) -> int: + return 5 + + 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: ActionRowComponent) -> 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[Item] | None = None) -> Self: + """ + Disables all items in this row. + + Parameters + ---------- + exclusions: Optional[List[:class:`Item`]] + A list of items in `self.items` to not disable from the view. + """ + 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[Item] | None = None) -> Self: + """ + Enables all buttons and select menus in the container. + + Parameters + ---------- + exclusions: Optional[List[:class:`Item`]] + 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[Item]: + for item in self.items: + yield item + + def to_component_dict(self) -> ContainerComponentPayload: + self._set_components(self.items) + return self._underlying.to_dict() + + @classmethod + def from_component(cls: type[C], component: ActionRowComponent) -> C: + from .view import _component_to_item + + items = [ + _component_to_item(c) for c in _walk_all_components(component.components) + ] + return cls( + *items, + id=component.id, + ) + + callback = None From fcb0849237a4925d70dbaeff7a898c883775b5e9 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Wed, 3 Sep 2025 13:02:18 +0000 Subject: [PATCH 03/99] style(pre-commit): auto fixes from pre-commit.com hooks --- discord/ui/action_row.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/discord/ui/action_row.py b/discord/ui/action_row.py index 8c8a7f9330..6a94cb4436 100644 --- a/discord/ui/action_row.py +++ b/discord/ui/action_row.py @@ -186,7 +186,9 @@ def add_button( return self.add_item(section) - def add_select(self, url: str, spoiler: bool = False, id: int | None = None) -> Self: + def add_select( + self, url: str, spoiler: bool = False, id: int | None = None + ) -> Self: """Adds a :class:`TextDisplay` to the container. Parameters @@ -263,8 +265,7 @@ def enable_all_items(self, *, exclusions: list[Item] | None = None) -> Self: return self def walk_items(self) -> Iterator[Item]: - for item in self.items: - yield item + yield from self.items def to_component_dict(self) -> ContainerComponentPayload: self._set_components(self.items) From d1f33ea37d2e399f4e4d520a36749b5815933722 Mon Sep 17 00:00:00 2001 From: UK <41271523+NeloBlivion@users.noreply.github.com> Date: Wed, 3 Sep 2025 18:37:51 +0100 Subject: [PATCH 04/99] cleanup --- discord/components.py | 2 +- discord/ui/action_row.py | 137 ++++++++++++++++++++++++++++----------- discord/ui/select.py | 2 +- discord/ui/view.py | 2 +- 4 files changed, 102 insertions(+), 41 deletions(-) diff --git a/discord/components.py b/discord/components.py index a798edb74d..eb729c231c 100644 --- a/discord/components.py +++ b/discord/components.py @@ -173,7 +173,7 @@ def __init__(self, data: ComponentPayload): @property def width(self): - """Return the sum of the children's widths.""" + """Return the sum of the item's widths.""" t = 0 for item in self.children: t += 1 if item.type is ComponentType.button else 5 diff --git a/discord/ui/action_row.py b/discord/ui/action_row.py index 6a94cb4436..7bd0ab210f 100644 --- a/discord/ui/action_row.py +++ b/discord/ui/action_row.py @@ -3,18 +3,14 @@ from functools import partial from typing import TYPE_CHECKING, ClassVar, Iterator, TypeVar -from ..colour import Colour from ..components import ActionRow as ActionRowComponent -from ..components import Container as ContainerComponent -from ..components import _component_factory -from ..enums import ComponentType, SeparatorSpacingSize +from ..components import _component_factory, SelectOption +from ..enums import ComponentType, ButtonStyle, ChannelType from ..utils import find, get from .file import File +from .button import Button +from .select import Select from .item import Item, ItemCallbackType -from .media_gallery import MediaGallery -from .section import Section -from .separator import Separator -from .text_display import TextDisplay from .view import _walk_all_components __all__ = ("Container",) @@ -22,22 +18,23 @@ if TYPE_CHECKING: from typing_extensions import Self - from ..types.components import ContainerComponent as ContainerComponentPayload + from ..emoji import AppEmoji, GuildEmoji + from ..types.components import ActionRow as ActionRowPayload + from ..partial_emoji import PartialEmoji, _EmojiTag from .view import View -AC = TypeVar("A", bound="ActionRow") +A = TypeVar("A", bound="ActionRow") V = TypeVar("V", bound="View", covariant=True) class ActionRow(Item[V]): - """Represents a UI Action Row. + """Represents a UI Action Row used in :class:`discord.ui.View`. The items supported are as follows: - :class:`discord.ui.Select` - - :class:`discord.ui.Button` (in views) - - :class:`discord.ui.InputText` (in modals) + - :class:`discord.ui.Button` .. versionadded:: 2.7 @@ -162,7 +159,13 @@ def get_item(self, id: str | int) -> Item | None: def add_button( self, *, - accessory: Item, + 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. @@ -172,38 +175,96 @@ def add_button( Parameters ---------- - *items: :class:`Item` - The items contained in this section, up to 3. - Currently only supports :class:`~discord.ui.TextDisplay`. - accessory: Optional[:class:`Item`] - 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`. + 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 section's ID. + The button's ID. """ - section = Section(*items, accessory=accessory, id=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(section) + return self.add_item(button) def add_select( - self, url: str, spoiler: bool = False, id: int | None = None + 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, ) -> Self: """Adds a :class:`TextDisplay` 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. + 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. """ - f = File(url, spoiler=spoiler, id=id) + 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, + ) - return self.add_item(f) + return self.add_item(select) @Item.view.setter def view(self, value): @@ -236,12 +297,12 @@ def refresh_component(self, component: ActionRowComponent) -> None: def disable_all_items(self, *, exclusions: list[Item] | None = None) -> Self: """ - Disables all items in this row. + Disables all items in the row. Parameters ---------- exclusions: Optional[List[:class:`Item`]] - A list of items in `self.items` to not disable from the view. + A list of items in `self.items` to not disable. """ for item in self.walk_items(): if exclusions is None or item not in exclusions: @@ -250,12 +311,12 @@ def disable_all_items(self, *, exclusions: list[Item] | None = None) -> Self: def enable_all_items(self, *, exclusions: list[Item] | None = None) -> Self: """ - Enables all buttons and select menus in the container. + Enables all items in the row. Parameters ---------- exclusions: Optional[List[:class:`Item`]] - A list of items in `self.items` to not enable from the view. + A list of items in `self.items` to not enable. """ for item in self.walk_items(): if hasattr(item, "disabled") and ( @@ -267,12 +328,12 @@ def enable_all_items(self, *, exclusions: list[Item] | None = None) -> Self: def walk_items(self) -> Iterator[Item]: yield from self.items - def to_component_dict(self) -> ContainerComponentPayload: + def to_component_dict(self) -> ActionRowPayload: self._set_components(self.items) return self._underlying.to_dict() @classmethod - def from_component(cls: type[C], component: ActionRowComponent) -> C: + def from_component(cls: type[A], component: ActionRowComponent) -> A: from .view import _component_to_item items = [ diff --git a/discord/ui/select.py b/discord/ui/select.py index d39aad0981..e7b19e0b2a 100644 --- a/discord/ui/select.py +++ b/discord/ui/select.py @@ -109,7 +109,7 @@ class Select(Item[V]): 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. Only useable in views. Defaults to ``True`` in views. + Whether the select is disabled or not. Only useable in views. Defaults to ``False`` in views. row: Optional[:class:`int`] 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 diff --git a/discord/ui/view.py b/discord/ui/view.py index 2b9b1a7d0f..43c6d220ef 100644 --- a/discord/ui/view.py +++ b/discord/ui/view.py @@ -428,7 +428,7 @@ def remove_item(self, item: Item[V] | int | str) -> None: if isinstance(item, (str, int)): item = self.get_item(item) try: - self.children.remove(item) + item.parent.remove_item(item) except ValueError: pass else: From 8efedd33e02c872fae0742f21cf852e2d39e1f93 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Wed, 3 Sep 2025 17:38:17 +0000 Subject: [PATCH 05/99] style(pre-commit): auto fixes from pre-commit.com hooks --- discord/ui/action_row.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/discord/ui/action_row.py b/discord/ui/action_row.py index 7bd0ab210f..86372fedbe 100644 --- a/discord/ui/action_row.py +++ b/discord/ui/action_row.py @@ -4,13 +4,13 @@ from typing import TYPE_CHECKING, ClassVar, Iterator, TypeVar from ..components import ActionRow as ActionRowComponent -from ..components import _component_factory, SelectOption -from ..enums import ComponentType, ButtonStyle, ChannelType +from ..components import SelectOption, _component_factory +from ..enums import ButtonStyle, ChannelType, ComponentType from ..utils import find, get -from .file import File from .button import Button -from .select import Select +from .file import File from .item import Item, ItemCallbackType +from .select import Select from .view import _walk_all_components __all__ = ("Container",) @@ -19,8 +19,8 @@ from typing_extensions import Self from ..emoji import AppEmoji, GuildEmoji - from ..types.components import ActionRow as ActionRowPayload from ..partial_emoji import PartialEmoji, _EmojiTag + from ..types.components import ActionRow as ActionRowPayload from .view import View From 6d6ce3edc528977f5248878846a14ab2f47f7339 Mon Sep 17 00:00:00 2001 From: UK <41271523+NeloBlivion@users.noreply.github.com> Date: Wed, 3 Sep 2025 19:39:26 +0100 Subject: [PATCH 06/99] eh..... going through it... don't judge yet................ --- discord/ui/action_row.py | 5 +- discord/ui/designer_view.py | 436 ++++++++++++++++++++++++++++++++++++ discord/ui/view.py | 2 +- 3 files changed, 439 insertions(+), 4 deletions(-) create mode 100644 discord/ui/designer_view.py diff --git a/discord/ui/action_row.py b/discord/ui/action_row.py index 86372fedbe..cb340ef4ea 100644 --- a/discord/ui/action_row.py +++ b/discord/ui/action_row.py @@ -11,9 +11,8 @@ from .file import File from .item import Item, ItemCallbackType from .select import Select -from .view import _walk_all_components -__all__ = ("Container",) +__all__ = ("ActionRow",) if TYPE_CHECKING: from typing_extensions import Self @@ -334,7 +333,7 @@ def to_component_dict(self) -> ActionRowPayload: @classmethod def from_component(cls: type[A], component: ActionRowComponent) -> A: - from .view import _component_to_item + from .view import _component_to_item, _walk_all_components items = [ _component_to_item(c) for c in _walk_all_components(component.components) diff --git a/discord/ui/designer_view.py b/discord/ui/designer_view.py new file mode 100644 index 0000000000..b49283effb --- /dev/null +++ b/discord/ui/designer_view.py @@ -0,0 +1,436 @@ +""" +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 asyncio +import os +import sys +import time +from functools import partial +from itertools import groupby +from typing import TYPE_CHECKING, Any, Callable, ClassVar, Iterator, Sequence, TypeVar + +from ..components import ActionRow as ActionRowComponent +from ..components import Button as ButtonComponent +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 +from ..components import Separator as SeparatorComponent +from ..components import TextDisplay as TextDisplayComponent +from ..components import Thumbnail as ThumbnailComponent +from ..components import _component_factory +from ..utils import find, get +from .item import Item, ItemCallbackType +from .action_row import ActionRow +from .view import View + +__all__ = ("DesignerView", "_component_to_item", "_walk_all_components") + + +if TYPE_CHECKING: + from typing_extensions import Self + from ..interactions import Interaction, InteractionMessage + from ..message import Message + from ..state import ConnectionState + from ..types.components import Component as ComponentPayload + +V = TypeVar("V", bound="DesignerView", covariant=True) + + +def _walk_all_components(components: list[Component]) -> Iterator[Component]: + for item in components: + if isinstance(item, ActionRowComponent): + yield from item.children + else: + yield item + + +def _walk_all_components_v2(components: list[Component]) -> Iterator[Component]: + for item in components: + if isinstance(item, ActionRowComponent): + yield from item.children + elif isinstance(item, (SectionComponent, ContainerComponent)): + yield from item.walk_components() + else: + yield item + + +def _component_to_item(component: Component) -> Item[V]: + + if isinstance(component, ActionRowComponent): + + return ActionRow.from_component(component) + if isinstance(component, ButtonComponent): + from .button import Button + + return Button.from_component(component) + if isinstance(component, SelectComponent): + from .select import Select + + return Select.from_component(component) + if isinstance(component, SectionComponent): + from .section import Section + + return Section.from_component(component) + if isinstance(component, TextDisplayComponent): + from .text_display import TextDisplay + + return TextDisplay.from_component(component) + if isinstance(component, ThumbnailComponent): + from .thumbnail import Thumbnail + + return Thumbnail.from_component(component) + if isinstance(component, MediaGalleryComponent): + from .media_gallery import MediaGallery + + return MediaGallery.from_component(component) + if isinstance(component, FileComponent): + from .file import File + + return File.from_component(component) + if isinstance(component, SeparatorComponent): + from .separator import Separator + + return Separator.from_component(component) + if isinstance(component, ContainerComponent): + from .container import Container + + return Container.from_component(component) + return Item.from_component(component) + + +class DesignerView: + """Represents a UI view for v2 components. + + This object must be inherited to create a UI within Discord. + + .. versionadded:: 2.7 + + Parameters + ---------- + *items: :class:`Item` + The initial items attached to this view. + 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 items attached to this view. + disable_on_timeout: :class:`bool` + Whether to disable the view's items when the timeout is reached. Defaults to ``False``. + message: Optional[:class:`.Message`] + The message that this view is attached to. + If ``None`` then the view has not been sent with a message. + parent: Optional[:class:`.Interaction`] + The parent interaction which this view was sent from. + If ``None`` then the view was not sent using :meth:`InteractionResponse.send_message`. + """ + + def __init__( + self, + *children: Item[V], + timeout: float | None = 180.0, + disable_on_timeout: bool = False, + ): + self.timeout = timeout + self.disable_on_timeout = disable_on_timeout + self.items: list[Item[V]] = [] + for item in items: + self.add_item(item) + + loop = asyncio.get_running_loop() + self.id: str = os.urandom(16).hex() + 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._message: Message | InteractionMessage | None = None + self.parent: Interaction | None = None + + def __repr__(self) -> str: + return f"<{self.__class__.__name__} timeout={self.timeout} children={len(self.children)}>" + + def to_components(self) -> list[dict[str, Any]]: + return [item.to_component_dict() for item in self.children] + + @classmethod + def from_message( + cls, message: Message, /, *, timeout: float | None = 180.0 + ) -> View: + """Converts a message's components into a :class:`DesignerView`. + + The :attr:`.Message.components` of a message are read-only + and separate types from those in the ``discord.ui`` namespace. + In order to modify and edit message components they must be + converted into a :class:`View` first. + + Parameters + ---------- + message: :class:`.Message` + The message with components to convert into a view. + timeout: Optional[:class:`float`] + The timeout of the converted view. + + Returns + ------- + :class:`View` + The converted view. This always returns a :class:`View` and not + one of its subclasses. + """ + view = DesignerView(timeout=timeout) + for component in message.components: + view.add_item(_component_to_item(component)) + return view + + @classmethod + def from_dict( + cls, + data: list[Component], + /, + *, + timeout: float | None = 180.0, + ) -> View: + """Converts a list of component dicts into a :class:`DesignerView`. + + Parameters + ---------- + data: List[:class:`.Component`] + The list of components to convert into a view. + timeout: Optional[:class:`float`] + The timeout of the converted view. + + Returns + ------- + :class:`DesignerView` + The converted view. This always returns a :class:`View` and not + one of its subclasses. + """ + view = DesignerView(timeout=timeout) + components = [_component_factory(d) for d in data] + for component in components: + view.add_item(_component_to_item(component)) + return view + + def add_item(self, item: Item[V]) -> Self: + """Adds an item to the view. + + Parameters + ---------- + item: :class:`Item` + The item to add to the view. + + Raises + ------ + TypeError + An :class:`Item` was not passed. + ValueError + Maximum number of items has been exceeded (40) + """ + + if len(self.children) >= 40: + raise ValueError("maximum number of children exceeded") + + 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." + ) + + item.parent = self + item._view = self + if hasattr(item, "items"): + item.view = self + self.children.append(item) + return self + + def remove_item(self, item: Item[V] | int | str) -> Self: + """Removes an item from the view. 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:`Item`, :class:`int`, :class:`str`] + The item, item ``id``, or item ``custom_id`` to remove from the view. + """ + + if isinstance(item, (str, int)): + item = self.get_item(item) + try: + item.parent.remove_item(item) + except ValueError: + pass + return self + + def clear_items(self) -> None: + """Removes all items from the view.""" + self.children.clear() + return self + + def get_item(self, id: str | int) -> Item[V] | None: + """Gets an item from the view. Roughly equal to `utils.get(view.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 + ---------- + 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 id: + return None + attr = "id" if isinstance(id, int) else "custom_id" + child = find(lambda i: getattr(i, attr, None) == id, self.children) + if not child: + for i in self.children: + if hasattr(i, "get_item"): + if child := i.get_item(id): + return child + return child + + __timeout_task_impl = View._View__timeout_task_impl + _expires_at = view._expires_at + _scheduled_task = View._scheduled_task + _start_listening_from_store = View._start_listening_from_store + _dispatch_timeout = View._dispatch_timeout + _dispatch_item = View._dispatch_item + stop = View.stop + wait = View.wait + is_finished = View.is_finished + is_dispatching = View.is_dispatching + interaction_check = View.interaction_check + on_timeout = View.on_timeout + on_check_failure = View.on_check_failure + on_error = View.on_error + + def refresh(self, components: list[Component]): + # Refreshes view data using discord's values + # Assumes the components and items are identical + if not components: + return + + i = 0 + for c in components: + try: + item = self.children[i] + except: + break + else: + item.refresh_component(c) + i += 1 + + def is_dispatchable(self) -> bool: + return any(item.is_dispatchable() for item in self.children) + + def is_persistent(self) -> bool: + """Whether the view is set up as persistent. + + A persistent view has all buttons and selects with a set ``custom_id`` and + a :attr:`timeout` set to ``None``. + """ + return self.timeout is None and all( + item.is_persistent() for item in self.children + ) + + def is_components_v2(self) -> bool: + """Whether the view contains V2 components. + + A view containing V2 components cannot be sent alongside message content or embeds. + This always returns ``True`` in :class:`DesignerView`, regardless of its :attr:`items` + """ + return True + + def disable_all_items(self, *, exclusions: list[Item[V]] | None = None) -> None: + """ + Disables all buttons and select menus in the view. + + Parameters + ---------- + exclusions: Optional[List[:class:`Item`]] + A list of items in `self.children` to not disable from the view. + """ + for child in self.children: + if hasattr(child, "disabled") and ( + exclusions is None or child not in exclusions + ): + child.disabled = True + if hasattr(child, "disable_all_items"): + child.disable_all_items(exclusions=exclusions) + return self + + def enable_all_items(self, *, exclusions: list[Item[V]] | None = None) -> None: + """ + Enables all buttons and select menus in the view. + + Parameters + ---------- + exclusions: Optional[List[:class:`Item`]] + A list of items in `self.children` to not enable from the view. + """ + for child in self.children: + if hasattr(child, "disabled") and ( + exclusions is None or child not in exclusions + ): + child.disabled = False + if hasattr(child, "enable_all_items"): + child.enable_all_items(exclusions=exclusions) + return self + + def walk_children(self) -> Iterator[Item]: + for item in self.children: + if hasattr(item, "walk_items"): + yield from item.walk_items() + else: + yield item + + def copy_text(self) -> str: + """Returns the text of all :class:`~discord.ui.TextDisplay` items in this View. + Equivalent to the `Copy Text` option on Discord clients. + """ + return "\n".join(t for i in self.children if (t := i.copy_text())) + + @property + def message(self): + return self._message + + @message.setter + def message(self, value): + self._message = value diff --git a/discord/ui/view.py b/discord/ui/view.py index 43c6d220ef..2b9b1a7d0f 100644 --- a/discord/ui/view.py +++ b/discord/ui/view.py @@ -428,7 +428,7 @@ def remove_item(self, item: Item[V] | int | str) -> None: if isinstance(item, (str, int)): item = self.get_item(item) try: - item.parent.remove_item(item) + self.children.remove(item) except ValueError: pass else: From f13f5e072b9d651aa1ef234481f2d0c8d3e2f104 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Wed, 3 Sep 2025 18:39:54 +0000 Subject: [PATCH 07/99] style(pre-commit): auto fixes from pre-commit.com hooks --- discord/ui/designer_view.py | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/discord/ui/designer_view.py b/discord/ui/designer_view.py index b49283effb..8b3eb48aa6 100644 --- a/discord/ui/designer_view.py +++ b/discord/ui/designer_view.py @@ -27,11 +27,8 @@ import asyncio import os -import sys -import time -from functools import partial from itertools import groupby -from typing import TYPE_CHECKING, Any, Callable, ClassVar, Iterator, Sequence, TypeVar +from typing import TYPE_CHECKING, Any, Callable, Iterator, TypeVar from ..components import ActionRow as ActionRowComponent from ..components import Button as ButtonComponent @@ -47,8 +44,8 @@ from ..components import Thumbnail as ThumbnailComponent from ..components import _component_factory from ..utils import find, get -from .item import Item, ItemCallbackType from .action_row import ActionRow +from .item import Item, ItemCallbackType from .view import View __all__ = ("DesignerView", "_component_to_item", "_walk_all_components") @@ -56,6 +53,7 @@ if TYPE_CHECKING: from typing_extensions import Self + from ..interactions import Interaction, InteractionMessage from ..message import Message from ..state import ConnectionState @@ -325,7 +323,7 @@ def get_item(self, id: str | int) -> Item[V] | None: if child := i.get_item(id): return child return child - + __timeout_task_impl = View._View__timeout_task_impl _expires_at = view._expires_at _scheduled_task = View._scheduled_task @@ -374,7 +372,7 @@ def is_components_v2(self) -> bool: """Whether the view contains V2 components. A view containing V2 components cannot be sent alongside message content or embeds. - This always returns ``True`` in :class:`DesignerView`, regardless of its :attr:`items` + This always returns ``True`` in :class:`DesignerView`, regardless of its :attr:`items` """ return True From b322b424ac7537c6d3931fdc543bb5d40b7bb059 Mon Sep 17 00:00:00 2001 From: UK <41271523+NeloBlivion@users.noreply.github.com> Date: Wed, 3 Sep 2025 21:12:29 +0100 Subject: [PATCH 08/99] correct --- discord/ui/designer_view.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/discord/ui/designer_view.py b/discord/ui/designer_view.py index 8b3eb48aa6..dc8daddeca 100644 --- a/discord/ui/designer_view.py +++ b/discord/ui/designer_view.py @@ -165,7 +165,7 @@ def __init__( self.timeout = timeout self.disable_on_timeout = disable_on_timeout self.items: list[Item[V]] = [] - for item in items: + for item in children: self.add_item(item) loop = asyncio.get_running_loop() @@ -325,7 +325,7 @@ def get_item(self, id: str | int) -> Item[V] | None: return child __timeout_task_impl = View._View__timeout_task_impl - _expires_at = view._expires_at + _expires_at = View._expires_at _scheduled_task = View._scheduled_task _start_listening_from_store = View._start_listening_from_store _dispatch_timeout = View._dispatch_timeout From c0022f1273f90d3c3c290269d5e83836f5a31260 Mon Sep 17 00:00:00 2001 From: UK <41271523+NeloBlivion@users.noreply.github.com> Date: Wed, 3 Sep 2025 21:13:31 +0100 Subject: [PATCH 09/99] v2 --- discord/ui/designer_view.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/discord/ui/designer_view.py b/discord/ui/designer_view.py index dc8daddeca..0979bce9ad 100644 --- a/discord/ui/designer_view.py +++ b/discord/ui/designer_view.py @@ -369,12 +369,11 @@ def is_persistent(self) -> bool: ) def is_components_v2(self) -> bool: - """Whether the view contains V2 components. + """Whether the view contains V2 components or requires the V2 flag. A view containing V2 components cannot be sent alongside message content or embeds. - This always returns ``True`` in :class:`DesignerView`, regardless of its :attr:`items` """ - return True + return len(self.children) > 5 or any(i.is_components_v2() for i in self.children) def disable_all_items(self, *, exclusions: list[Item[V]] | None = None) -> None: """ From e830df42308ce3106077aef3606bb6f06412213d Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Wed, 3 Sep 2025 20:14:34 +0000 Subject: [PATCH 10/99] style(pre-commit): auto fixes from pre-commit.com hooks --- discord/ui/designer_view.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/discord/ui/designer_view.py b/discord/ui/designer_view.py index 0979bce9ad..24a1683b5c 100644 --- a/discord/ui/designer_view.py +++ b/discord/ui/designer_view.py @@ -373,7 +373,9 @@ def is_components_v2(self) -> bool: A view containing V2 components cannot be sent alongside message content or embeds. """ - return len(self.children) > 5 or any(i.is_components_v2() for i in self.children) + return len(self.children) > 5 or any( + i.is_components_v2() for i in self.children + ) def disable_all_items(self, *, exclusions: list[Item[V]] | None = None) -> None: """ From 23f296d4fd1a2209f6e6a5b761a9c4bd4da49378 Mon Sep 17 00:00:00 2001 From: UK <41271523+NeloBlivion@users.noreply.github.com> Date: Mon, 22 Sep 2025 13:10:43 -0400 Subject: [PATCH 11/99] better separation of views --- discord/ui/core.py | 170 +++++++++ discord/ui/designer_view.py | 435 ---------------------- discord/ui/view.py | 710 ++++++++++++++++++++++-------------- 3 files changed, 598 insertions(+), 717 deletions(-) create mode 100644 discord/ui/core.py delete mode 100644 discord/ui/designer_view.py diff --git a/discord/ui/core.py b/discord/ui/core.py new file mode 100644 index 0000000000..8f0419ebc6 --- /dev/null +++ b/discord/ui/core.py @@ -0,0 +1,170 @@ +""" +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 +from itertools import groupby +from typing import TYPE_CHECKING, Any, Callable, Iterator, TypeVar + +from ..components import ActionRow as ActionRowComponent +from ..components import Button as ButtonComponent +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 +from ..components import Separator as SeparatorComponent +from ..components import TextDisplay as TextDisplayComponent +from ..components import Thumbnail as ThumbnailComponent +from ..components import _component_factory +from ..utils import find, get +from .action_row import ActionRow +from .item import Item, ItemCallbackType +from .view import View + +__all__ = ("ComponentUI") + + +if TYPE_CHECKING: + from typing_extensions import Self + + from ..interactions import Interaction, InteractionMessage + from ..message import Message + from ..state import ConnectionState + from ..types.components import Component as ComponentPayload + +class ComponentUI: + """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. + """ + + def __init__( + self, + *items: Item, + timeout: float | None = 180.0, + ): + self.timeout = 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() + + 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. + """ \ No newline at end of file diff --git a/discord/ui/designer_view.py b/discord/ui/designer_view.py deleted file mode 100644 index 24a1683b5c..0000000000 --- a/discord/ui/designer_view.py +++ /dev/null @@ -1,435 +0,0 @@ -""" -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 asyncio -import os -from itertools import groupby -from typing import TYPE_CHECKING, Any, Callable, Iterator, TypeVar - -from ..components import ActionRow as ActionRowComponent -from ..components import Button as ButtonComponent -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 -from ..components import Separator as SeparatorComponent -from ..components import TextDisplay as TextDisplayComponent -from ..components import Thumbnail as ThumbnailComponent -from ..components import _component_factory -from ..utils import find, get -from .action_row import ActionRow -from .item import Item, ItemCallbackType -from .view import View - -__all__ = ("DesignerView", "_component_to_item", "_walk_all_components") - - -if TYPE_CHECKING: - from typing_extensions import Self - - from ..interactions import Interaction, InteractionMessage - from ..message import Message - from ..state import ConnectionState - from ..types.components import Component as ComponentPayload - -V = TypeVar("V", bound="DesignerView", covariant=True) - - -def _walk_all_components(components: list[Component]) -> Iterator[Component]: - for item in components: - if isinstance(item, ActionRowComponent): - yield from item.children - else: - yield item - - -def _walk_all_components_v2(components: list[Component]) -> Iterator[Component]: - for item in components: - if isinstance(item, ActionRowComponent): - yield from item.children - elif isinstance(item, (SectionComponent, ContainerComponent)): - yield from item.walk_components() - else: - yield item - - -def _component_to_item(component: Component) -> Item[V]: - - if isinstance(component, ActionRowComponent): - - return ActionRow.from_component(component) - if isinstance(component, ButtonComponent): - from .button import Button - - return Button.from_component(component) - if isinstance(component, SelectComponent): - from .select import Select - - return Select.from_component(component) - if isinstance(component, SectionComponent): - from .section import Section - - return Section.from_component(component) - if isinstance(component, TextDisplayComponent): - from .text_display import TextDisplay - - return TextDisplay.from_component(component) - if isinstance(component, ThumbnailComponent): - from .thumbnail import Thumbnail - - return Thumbnail.from_component(component) - if isinstance(component, MediaGalleryComponent): - from .media_gallery import MediaGallery - - return MediaGallery.from_component(component) - if isinstance(component, FileComponent): - from .file import File - - return File.from_component(component) - if isinstance(component, SeparatorComponent): - from .separator import Separator - - return Separator.from_component(component) - if isinstance(component, ContainerComponent): - from .container import Container - - return Container.from_component(component) - return Item.from_component(component) - - -class DesignerView: - """Represents a UI view for v2 components. - - This object must be inherited to create a UI within Discord. - - .. versionadded:: 2.7 - - Parameters - ---------- - *items: :class:`Item` - The initial items attached to this view. - 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 items attached to this view. - disable_on_timeout: :class:`bool` - Whether to disable the view's items when the timeout is reached. Defaults to ``False``. - message: Optional[:class:`.Message`] - The message that this view is attached to. - If ``None`` then the view has not been sent with a message. - parent: Optional[:class:`.Interaction`] - The parent interaction which this view was sent from. - If ``None`` then the view was not sent using :meth:`InteractionResponse.send_message`. - """ - - def __init__( - self, - *children: Item[V], - timeout: float | None = 180.0, - disable_on_timeout: bool = False, - ): - self.timeout = timeout - self.disable_on_timeout = disable_on_timeout - self.items: list[Item[V]] = [] - for item in children: - self.add_item(item) - - loop = asyncio.get_running_loop() - self.id: str = os.urandom(16).hex() - 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._message: Message | InteractionMessage | None = None - self.parent: Interaction | None = None - - def __repr__(self) -> str: - return f"<{self.__class__.__name__} timeout={self.timeout} children={len(self.children)}>" - - def to_components(self) -> list[dict[str, Any]]: - return [item.to_component_dict() for item in self.children] - - @classmethod - def from_message( - cls, message: Message, /, *, timeout: float | None = 180.0 - ) -> View: - """Converts a message's components into a :class:`DesignerView`. - - The :attr:`.Message.components` of a message are read-only - and separate types from those in the ``discord.ui`` namespace. - In order to modify and edit message components they must be - converted into a :class:`View` first. - - Parameters - ---------- - message: :class:`.Message` - The message with components to convert into a view. - timeout: Optional[:class:`float`] - The timeout of the converted view. - - Returns - ------- - :class:`View` - The converted view. This always returns a :class:`View` and not - one of its subclasses. - """ - view = DesignerView(timeout=timeout) - for component in message.components: - view.add_item(_component_to_item(component)) - return view - - @classmethod - def from_dict( - cls, - data: list[Component], - /, - *, - timeout: float | None = 180.0, - ) -> View: - """Converts a list of component dicts into a :class:`DesignerView`. - - Parameters - ---------- - data: List[:class:`.Component`] - The list of components to convert into a view. - timeout: Optional[:class:`float`] - The timeout of the converted view. - - Returns - ------- - :class:`DesignerView` - The converted view. This always returns a :class:`View` and not - one of its subclasses. - """ - view = DesignerView(timeout=timeout) - components = [_component_factory(d) for d in data] - for component in components: - view.add_item(_component_to_item(component)) - return view - - def add_item(self, item: Item[V]) -> Self: - """Adds an item to the view. - - Parameters - ---------- - item: :class:`Item` - The item to add to the view. - - Raises - ------ - TypeError - An :class:`Item` was not passed. - ValueError - Maximum number of items has been exceeded (40) - """ - - if len(self.children) >= 40: - raise ValueError("maximum number of children exceeded") - - 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." - ) - - item.parent = self - item._view = self - if hasattr(item, "items"): - item.view = self - self.children.append(item) - return self - - def remove_item(self, item: Item[V] | int | str) -> Self: - """Removes an item from the view. 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:`Item`, :class:`int`, :class:`str`] - The item, item ``id``, or item ``custom_id`` to remove from the view. - """ - - if isinstance(item, (str, int)): - item = self.get_item(item) - try: - item.parent.remove_item(item) - except ValueError: - pass - return self - - def clear_items(self) -> None: - """Removes all items from the view.""" - self.children.clear() - return self - - def get_item(self, id: str | int) -> Item[V] | None: - """Gets an item from the view. Roughly equal to `utils.get(view.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 - ---------- - 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 id: - return None - attr = "id" if isinstance(id, int) else "custom_id" - child = find(lambda i: getattr(i, attr, None) == id, self.children) - if not child: - for i in self.children: - if hasattr(i, "get_item"): - if child := i.get_item(id): - return child - return child - - __timeout_task_impl = View._View__timeout_task_impl - _expires_at = View._expires_at - _scheduled_task = View._scheduled_task - _start_listening_from_store = View._start_listening_from_store - _dispatch_timeout = View._dispatch_timeout - _dispatch_item = View._dispatch_item - stop = View.stop - wait = View.wait - is_finished = View.is_finished - is_dispatching = View.is_dispatching - interaction_check = View.interaction_check - on_timeout = View.on_timeout - on_check_failure = View.on_check_failure - on_error = View.on_error - - def refresh(self, components: list[Component]): - # Refreshes view data using discord's values - # Assumes the components and items are identical - if not components: - return - - i = 0 - for c in components: - try: - item = self.children[i] - except: - break - else: - item.refresh_component(c) - i += 1 - - def is_dispatchable(self) -> bool: - return any(item.is_dispatchable() for item in self.children) - - def is_persistent(self) -> bool: - """Whether the view is set up as persistent. - - A persistent view has all buttons and selects with a set ``custom_id`` and - a :attr:`timeout` set to ``None``. - """ - return self.timeout is None and all( - item.is_persistent() for item in self.children - ) - - def is_components_v2(self) -> bool: - """Whether the view contains V2 components or requires the V2 flag. - - A view containing V2 components cannot be sent alongside message content or embeds. - """ - return len(self.children) > 5 or any( - i.is_components_v2() for i in self.children - ) - - def disable_all_items(self, *, exclusions: list[Item[V]] | None = None) -> None: - """ - Disables all buttons and select menus in the view. - - Parameters - ---------- - exclusions: Optional[List[:class:`Item`]] - A list of items in `self.children` to not disable from the view. - """ - for child in self.children: - if hasattr(child, "disabled") and ( - exclusions is None or child not in exclusions - ): - child.disabled = True - if hasattr(child, "disable_all_items"): - child.disable_all_items(exclusions=exclusions) - return self - - def enable_all_items(self, *, exclusions: list[Item[V]] | None = None) -> None: - """ - Enables all buttons and select menus in the view. - - Parameters - ---------- - exclusions: Optional[List[:class:`Item`]] - A list of items in `self.children` to not enable from the view. - """ - for child in self.children: - if hasattr(child, "disabled") and ( - exclusions is None or child not in exclusions - ): - child.disabled = False - if hasattr(child, "enable_all_items"): - child.enable_all_items(exclusions=exclusions) - return self - - def walk_children(self) -> Iterator[Item]: - for item in self.children: - if hasattr(item, "walk_items"): - yield from item.walk_items() - else: - yield item - - def copy_text(self) -> str: - """Returns the text of all :class:`~discord.ui.TextDisplay` items in this View. - Equivalent to the `Copy Text` option on Discord clients. - """ - return "\n".join(t for i in self.children if (t := i.copy_text())) - - @property - def message(self): - return self._message - - @message.setter - def message(self, value): - self._message = value diff --git a/discord/ui/view.py b/discord/ui/view.py index 83c32d24b3..2044ff1208 100644 --- a/discord/ui/view.py +++ b/discord/ui/view.py @@ -48,8 +48,9 @@ from ..components import _component_factory from ..utils import find from .item import Item, ItemCallbackType +from .core import ComponentUI -__all__ = ("View", "_component_to_item", "_walk_all_components") +__all__ = ("BaseView", "View", "DesignerView", "_component_to_item", "_walk_all_components") if TYPE_CHECKING: @@ -190,53 +191,10 @@ def fits_legacy(self, item) -> bool: return item.row <= 4 return self.weights[-1] + item.width <= 5 +class BaseView(ComponentUI): + """The base class for UI views used in messages.""" -class View: - """Represents a UI view. - - This object must be inherited to create a UI within Discord. - - .. versionadded:: 2.0 - - Parameters - ---------- - *items: :class:`Item` - The initial items attached to this view. - 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 view. - disable_on_timeout: :class:`bool` - Whether to disable the view when the timeout is reached. Defaults to ``False``. - message: Optional[:class:`.Message`] - The message that this view is attached to. - If ``None`` then the view has not been sent with a message. - parent: Optional[:class:`.Interaction`] - The parent interaction which this view was sent from. - If ``None`` then the view was not sent using :meth:`InteractionResponse.send_message`. - """ - - __discord_ui_view__: ClassVar[bool] = True - __view_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) - - if len(children) > 40: - raise TypeError("View cannot have more than 40 children") - - cls.__view_children_items__ = children + MAX_ITEMS: int def __init__( self, @@ -244,141 +202,13 @@ def __init__( timeout: float | None = 180.0, disable_on_timeout: bool = False, ): - self.timeout = timeout + super().__init__(*items, timeout=timeout) self.disable_on_timeout = disable_on_timeout - self.children: list[Item[V]] = [] - for func in self.__view_children_items__: - item: Item[V] = func.__discord_ui_model_type__( - **func.__discord_ui_model_kwargs__ - ) - item.callback = partial(func, self, item) - item._view = self - item.parent = self - setattr(self, func.__name__, item) - self.children.append(item) - - self.__weights = _ViewWeights(self.children) - for item in items: - self.add_item(item) - - loop = asyncio.get_running_loop() self.id: str = os.urandom(16).hex() - 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._message: Message | InteractionMessage | None = None self.parent: Interaction | None = None - 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) - - def to_components(self) -> list[dict[str, Any]]: - def key(item: Item[V]) -> 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): - items = list(group) - children = [item.to_component_dict() for item in items] - if not children: - continue - - if any([i._underlying.is_v2() for i in items]): - components += children - else: - components.append( - { - "type": 1, - "components": children, - } - ) - - return components - - @classmethod - def from_message( - cls, message: Message, /, *, timeout: float | None = 180.0 - ) -> View: - """Converts a message's components into a :class:`View`. - - The :attr:`.Message.components` of a message are read-only - and separate types from those in the ``discord.ui`` namespace. - In order to modify and edit message components they must be - converted into a :class:`View` first. - - Parameters - ---------- - message: :class:`.Message` - The message with components to convert into a view. - timeout: Optional[:class:`float`] - The timeout of the converted view. - - Returns - ------- - :class:`View` - The converted view. This always returns a :class:`View` and not - one of its subclasses. - """ - view = View(timeout=timeout) - for component in _walk_all_components(message.components): - view.add_item(_component_to_item(component)) - return view - - @classmethod - def from_dict( - cls, - data: list[Component], - /, - *, - timeout: float | None = 180.0, - ) -> View: - """Converts a list of component dicts into a :class:`View`. - - Parameters - ---------- - data: List[:class:`.Component`] - The list of components to convert into a view. - timeout: Optional[:class:`float`] - The timeout of the converted view. - - Returns - ------- - :class:`View` - The converted view. This always returns a :class:`View` and not - one of its subclasses. - """ - view = View(timeout=timeout) - components = [_component_factory(d) for d in data] - for component in _walk_all_components(components): - view.add_item(_component_to_item(component)) - return view - - @property - def _expires_at(self) -> float | None: - if self.timeout: - return time.monotonic() + self.timeout - return None - - def add_item(self, item: Item[V]) -> None: + def add_item(self, item: Item[V]) -> Self: """Adds an item to the view. Parameters @@ -391,27 +221,17 @@ def add_item(self, item: Item[V]) -> None: TypeError An :class:`Item` was not passed. ValueError - Maximum number of children has been exceeded (40) - or the row the item is trying to be added to is full. + Maximum number of children has been exceeded """ - if len(self.children) >= 40: + if len(self.children) >= self.MAX_ITEMS: raise ValueError("maximum number of children exceeded") 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 item._view = self - if hasattr(item, "items"): - item.view = self self.children.append(item) return self @@ -428,45 +248,19 @@ def remove_item(self, item: Item[V] | int | str) -> None: if isinstance(item, (str, int)): item = self.get_item(item) try: - self.children.remove(item) + if isinstance(item.parent, BaseView): + self.children.remove(item) + else: + item.parent.remove_item(item) except ValueError: pass - else: - self.__weights.remove_item(item) return self def clear_items(self) -> None: - """Removes all items from the view.""" + """Removes all items from this view.""" self.children.clear() - self.__weights.clear() return self - def get_item(self, custom_id: str | int) -> Item[V] | None: - """Gets an item from the view. Roughly equal to `utils.get(view.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 - async def interaction_check(self, interaction: Interaction) -> bool: """|coro| @@ -517,7 +311,7 @@ async def on_timeout(self) -> None: async def on_check_failure(self, interaction: Interaction) -> None: """|coro| - A callback that is called when a :meth:`View.interaction_check` returns ``False``. + A callback that is called when a :meth:`BaseView.interaction_check` returns ``False``. This can be used to send a response when a check failure occurs. Parameters @@ -547,6 +341,13 @@ async def on_error( """ interaction.client.dispatch("view_error", error, item, interaction) + def is_components_v2(self) -> bool: + """Whether the view contains V2 components. + + A view containing V2 components cannot be sent alongside message content or embeds. + """ + return any([item._underlying.is_v2() for item in self.children]) + async def _scheduled_task(self, item: Item[V], interaction: Interaction): try: if self.timeout: @@ -589,46 +390,6 @@ def _dispatch_item(self, item: Item[V], interaction: Interaction): asyncio.create_task( self._scheduled_task(item, interaction), name=f"discord-ui-view-dispatch-{self.id}", - ) - - def refresh(self, components: list[Component]): - # Refreshes view data using discord's values - # Assumes the components and items are identical - if not components: - return - - i = 0 - flattened = [] - for c in components: - if isinstance(c, ActionRowComponent): - flattened += c.children - else: - flattened.append(c) - for c in flattened: - try: - item = self.children[i] - except: - break - else: - item.refresh_component(c) - i += 1 - - def stop(self) -> None: - """Stops listening to interaction events from this view. - - This operation cannot be undone. - """ - if not self.__stopped.done(): - self.__stopped.set_result(False) - - self.__timeout_expiry = None - if self.__timeout_task is not None: - self.__timeout_task.cancel() - self.__timeout_task = None - - if self.__cancel_callback: - self.__cancel_callback(self) - self.__cancel_callback = None def is_finished(self) -> bool: """Whether the view has finished interacting.""" @@ -651,14 +412,22 @@ def is_persistent(self) -> bool: item.is_persistent() for item in self.children ) - def is_components_v2(self) -> bool: - """Whether the view contains V2 components. + def stop(self) -> None: + """Stops listening to interaction events from this view. - A view containing V2 components cannot be sent alongside message content or embeds. + This operation cannot be undone. """ - return ( - any([item._underlying.is_v2() for item in self.children]) - or self.__weights.requires_v2() + if not self.__stopped.done(): + self.__stopped.set_result(False) + + self.__timeout_expiry = None + if self.__timeout_task is not None: + self.__timeout_task.cancel() + self.__timeout_task = None + + if self.__cancel_callback: + self.__cancel_callback(self) + self.__cancel_callback = None ) async def wait(self) -> bool: @@ -675,7 +444,7 @@ async def wait(self) -> bool: """ return await self.__stopped - def disable_all_items(self, *, exclusions: list[Item[V]] | None = None) -> None: + def disable_all_items(self, *, exclusions: list[Item[V]] | None = None) -> Self: """ Disables all buttons and select menus in the view. @@ -693,7 +462,7 @@ def disable_all_items(self, *, exclusions: list[Item[V]] | None = None) -> None: child.disable_all_items(exclusions=exclusions) return self - def enable_all_items(self, *, exclusions: list[Item[V]] | None = None) -> None: + def enable_all_items(self, *, exclusions: list[Item[V]] | None = None) -> Self: """ Enables all buttons and select menus in the view. @@ -711,6 +480,12 @@ def enable_all_items(self, *, exclusions: list[Item[V]] | None = None) -> None: child.enable_all_items(exclusions=exclusions) return self + def copy_text(self) -> str: + """Returns the text of all :class:`~discord.ui.TextDisplay` items in this View. + Equivalent to the `Copy Text` option on Discord clients. + """ + return "\n".join(t for i in self.children if (t := i.copy_text())) + def walk_children(self) -> Iterator[Item]: for item in self.children: if hasattr(item, "walk_items"): @@ -718,12 +493,6 @@ def walk_children(self) -> Iterator[Item]: else: yield item - def copy_text(self) -> str: - """Returns the text of all :class:`~discord.ui.TextDisplay` items in this View. - Equivalent to the `Copy Text` option on Discord clients. - """ - return "\n".join(t for i in self.children if (t := i.copy_text())) - @property def message(self): return self._message @@ -732,17 +501,394 @@ def message(self): def message(self, value): self._message = value +class View(BaseView): + """Represents a UI view for v1 components :class:`~discord.ui.Button` and :class:`~discord.ui.Select`. + + This object must be inherited to create a UI within Discord. + + .. versionadded:: 2.0 + + Parameters + ---------- + *items: :class:`Item` + The initial items attached to this view. + 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 view. + disable_on_timeout: :class:`bool` + Whether to disable the view when the timeout is reached. Defaults to ``False``. + message: Optional[:class:`.Message`] + The message that this view is attached to. + If ``None`` then the view has not been sent with a message. + parent: Optional[:class:`.Interaction`] + The parent interaction which this view was sent from. + If ``None`` then the view was not sent using :meth:`InteractionResponse.send_message`. + """ + + __discord_ui_view__: ClassVar[bool] = True + __view_children_items__: ClassVar[list[ItemCallbackType]] = [] + MAX_ITEMS: int = 25 + + 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) + + if len(children) > 40: + raise TypeError("View cannot have more than 40 children") + + cls.__view_children_items__ = children + + def __init__( + self, + *items: Item[V], + timeout: float | None = 180.0, + disable_on_timeout: bool = False, + ): + super().__init__(timeout=timeout, disable_on_timeout=disable_on_timeout) + + for func in self.__view_children_items__: + item: Item[V] = func.__discord_ui_model_type__( + **func.__discord_ui_model_kwargs__ + ) + item.callback = partial(func, self, item) + item._view = self + item.parent = self + setattr(self, func.__name__, item) + self.children.append(item) + + self.__weights = _ViewWeights(self.children) + for item in items: + self.add_item(item) + + def to_components(self) -> list[dict[str, Any]]: + def key(item: Item[V]) -> 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): + items = list(group) + children = [item.to_component_dict() for item in items] + if not children: + continue + + components.append( + { + "type": 1, + "components": children, + } + ) + + return components + + @classmethod + def from_message( + cls, message: Message, /, *, timeout: float | None = 180.0 + ) -> View: + """Converts a message's components into a :class:`View`. + + The :attr:`.Message.components` of a message are read-only + and separate types from those in the ``discord.ui`` namespace. + In order to modify and edit message components they must be + converted into a :class:`View` first. + + Parameters + ---------- + message: :class:`.Message` + The message with components to convert into a view. + timeout: Optional[:class:`float`] + The timeout of the converted view. + + Returns + ------- + :class:`View` + The converted view. This always returns a :class:`View` and not + one of its subclasses. + """ + view = View(timeout=timeout) + for component in _walk_all_components(message.components): + view.add_item(_component_to_item(component)) + return view + + @classmethod + def from_dict( + cls, + data: list[Component], + /, + *, + timeout: float | None = 180.0, + ) -> View: + """Converts a list of component dicts into a :class:`View`. + + Parameters + ---------- + data: List[:class:`.Component`] + The list of components to convert into a view. + timeout: Optional[:class:`float`] + The timeout of the converted view. + + Returns + ------- + :class:`View` + The converted view. This always returns a :class:`View` and not + one of its subclasses. + """ + view = View(timeout=timeout) + components = [_component_factory(d) for d in data] + for component in _walk_all_components(components): + view.add_item(_component_to_item(component)) + + def add_item(self, item: Item[V]) -> Self: + """Adds an item to the view. Attempting to add a :class:`~discord.ui.ActionRow` will add its children instead. + + Parameters + ---------- + item: :class:`Item` + The item to add to the view. + + Raises + ------ + TypeError + An :class:`Item` was not passed. + ValueError + Maximum number of children has been exceeded (25) + or the row the item is trying to be added to is full. + """ + + if item._underlying.is_v2(): + raise ValueError( + f"cannot use V2 components in View. Use DesignerView instead." + ) + if isinstance(item._underlying, ActionRowComponent): + for i in item.children: + self.add_item(i) + return self + + super().add_item(item) + self.__weights.add_item(item) + return self + + def remove_item(self, item: Item[V] | int | str) -> None: + """Removes an item from the view. 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:`Item`, :class:`int`, :class:`str`] + The item, item ``id``, or item ``custom_id`` to remove from the view. + """ + + super().remove_item(item) + try: + self.__weights.remove_item(item) + except ValueError: + pass + return self + + def clear_items(self) -> None: + """Removes all items from the view.""" + super().clear_items() + self.__weights.clear() + return self + + def refresh(self, components: list[Component]): + # This is pretty hacky at the moment + old_state: dict[tuple[int, str], Item[V]] = { + (item.type.value, item.custom_id): item for item in self.children if item.is_dispatchable() # type: ignore + } + children: list[Item[V]] = [ + item for item in self.children if not item.is_dispatchable() + ] + for component in _walk_all_components(components): + try: + older = old_state[(component.type.value, component.custom_id)] # type: ignore + except (KeyError, AttributeError): + item = _component_to_item(component) + if not item.is_dispatchable(): + continue + children.append(item) + else: + older.refresh_component(component) + children.append(older) + + self.children = children + + def is_components_v2(self) -> bool: + return False + +class DesignerView(BaseView): + """Represents a UI view compatible with v2 components. + + This object must be inherited to create a UI within Discord. + + .. versionadded:: 2.7 + + Parameters + ---------- + *items: :class:`Item` + The initial items attached to this view. + 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 items attached to this view. + disable_on_timeout: :class:`bool` + Whether to disable the view's items when the timeout is reached. Defaults to ``False``. + message: Optional[:class:`.Message`] + The message that this view is attached to. + If ``None`` then the view has not been sent with a message. + parent: Optional[:class:`.Interaction`] + The parent interaction which this view was sent from. + If ``None`` then the view was not sent using :meth:`InteractionResponse.send_message`. + """ + + MAX_ITEMS: int = 40 + + def __init__( + self, + *items: Item[V], + timeout: float | None = 180.0, + disable_on_timeout: bool = False, + ): + super().__init__(*items, timeout=timeout, disable_on_timeout=disable_on_timeout) + + @classmethod + def from_message( + cls, message: Message, /, *, timeout: float | None = 180.0 + ) -> View: + """Converts a message's components into a :class:`DesignerView`. + + The :attr:`.Message.components` of a message are read-only + and separate types from those in the ``discord.ui`` namespace. + In order to modify and edit message components they must be + converted into a :class:`View` first. + + Parameters + ---------- + message: :class:`.Message` + The message with components to convert into a view. + timeout: Optional[:class:`float`] + The timeout of the converted view. + + Returns + ------- + :class:`View` + The converted view. This always returns a :class:`View` and not + one of its subclasses. + """ + view = DesignerView(timeout=timeout) + for component in message.components: + view.add_item(_component_to_item(component)) + return view + + @classmethod + def from_dict( + cls, + data: list[Component], + /, + *, + timeout: float | None = 180.0, + ) -> View: + """Converts a list of component dicts into a :class:`DesignerView`. + + Parameters + ---------- + data: List[:class:`.Component`] + The list of components to convert into a view. + timeout: Optional[:class:`float`] + The timeout of the converted view. + + Returns + ------- + :class:`DesignerView` + The converted view. This always returns a :class:`View` and not + one of its subclasses. + """ + view = DesignerView(timeout=timeout) + components = [_component_factory(d) for d in data] + for component in components: + view.add_item(_component_to_item(component)) + return view + + def add_item(self, item: Item[V]) -> Self: + """Adds an item to the view. + + Parameters + ---------- + item: :class:`Item` + The item to add to the view. + + Raises + ------ + TypeError + An :class:`Item` was not passed. + ValueError + Maximum number of items has been exceeded (40) + """ + + if isinstance(item._underlying, (SelectComponent, ButtonComponent)): + raise ValueError( + f"cannot add Select or Button to DesignerView directly. Use ActionRow instead." + ) + + super().add_item(item) + if hasattr(item, "items"): + item.view = self + return self + + def refresh(self, components: list[Component]): + # Refreshes view data using discord's values + # Assumes the components and items are identical + if not components: + return + + i = 0 + for c in components: + try: + item = self.children[i] + except: + break + else: + item.refresh_component(c) + i += 1 + + def is_components_v2(self) -> bool: + """Whether the view contains V2 components or requires the V2 flag. + + A view containing V2 components cannot be sent alongside message content or embeds. + """ + return len(self.children) > 5 or any( + i.is_components_v2() for i in self.children + ) + class ViewStore: def __init__(self, state: ConnectionState): - # (component_type, message_id, custom_id): (View, Item) - self._views: dict[tuple[int, int | None, str], tuple[View, Item[V]]] = {} + # (component_type, message_id, custom_id): (BaseView, Item) + self._views: dict[tuple[int, int | None, str], tuple[BaseView, Item[V]]] = {} # message_id: View - self._synced_message_views: dict[int, View] = {} + self._synced_message_views: dict[int, BaseView] = {} self._state: ConnectionState = state @property - def persistent_views(self) -> Sequence[View]: + def persistent_views(self) -> Sequence[BaseView]: views = { view.id: view for (_, (view, _)) in self._views.items() @@ -759,7 +905,7 @@ def __verify_integrity(self): for k in to_remove: del self._views[k] - def add_view(self, view: View, message_id: int | None = None): + def add_view(self, view: BaseView, message_id: int | None = None): self.__verify_integrity() view._start_listening_from_store(self) @@ -770,7 +916,7 @@ def add_view(self, view: View, message_id: int | None = None): if message_id is not None: self._synced_message_views[message_id] = view - def remove_view(self, view: View): + def remove_view(self, view: BaseView): for item in view.walk_children(): if item.is_storable(): self._views.pop((item.type.value, item.custom_id), None) # type: ignore @@ -800,7 +946,7 @@ def dispatch(self, component_type: int, custom_id: str, interaction: Interaction def is_message_tracked(self, message_id: int): return message_id in self._synced_message_views - def remove_message_tracking(self, message_id: int) -> View | None: + def remove_message_tracking(self, message_id: int) -> BaseView | None: return self._synced_message_views.pop(message_id, None) def update_from_message(self, message_id: int, components: list[ComponentPayload]): From 57a0d982a0a45ba13de2c544389df4fb7a9c0b58 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Mon, 22 Sep 2025 17:11:11 +0000 Subject: [PATCH 12/99] style(pre-commit): auto fixes from pre-commit.com hooks --- discord/ui/core.py | 8 ++++---- discord/ui/view.py | 4 ++-- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/discord/ui/core.py b/discord/ui/core.py index 8f0419ebc6..36a57ffba1 100644 --- a/discord/ui/core.py +++ b/discord/ui/core.py @@ -25,9 +25,8 @@ from __future__ import annotations import asyncio -import os from itertools import groupby -from typing import TYPE_CHECKING, Any, Callable, Iterator, TypeVar +from typing import TYPE_CHECKING, Any, Callable from ..components import ActionRow as ActionRowComponent from ..components import Button as ButtonComponent @@ -47,7 +46,7 @@ from .item import Item, ItemCallbackType from .view import View -__all__ = ("ComponentUI") +__all__ = "ComponentUI" if TYPE_CHECKING: @@ -58,6 +57,7 @@ from ..state import ConnectionState from ..types.components import Component as ComponentPayload + class ComponentUI: """The base structure for classes that contain :class:`~discord.ui.Item`. @@ -167,4 +167,4 @@ async def on_timeout(self) -> None: """|coro| A callback that is called when this structure's timeout elapses without being explicitly stopped. - """ \ No newline at end of file + """ diff --git a/discord/ui/view.py b/discord/ui/view.py index 2044ff1208..3f84ac2c17 100644 --- a/discord/ui/view.py +++ b/discord/ui/view.py @@ -47,8 +47,8 @@ from ..components import Thumbnail as ThumbnailComponent from ..components import _component_factory from ..utils import find -from .item import Item, ItemCallbackType from .core import ComponentUI +from .item import Item, ItemCallbackType __all__ = ("BaseView", "View", "DesignerView", "_component_to_item", "_walk_all_components") @@ -674,7 +674,7 @@ def add_item(self, item: Item[V]) -> Self: for i in item.children: self.add_item(i) return self - + super().add_item(item) self.__weights.add_item(item) return self From eaa2c30f6c93f22025667ea01c6495b3f399280f Mon Sep 17 00:00:00 2001 From: UK <41271523+NeloBlivion@users.noreply.github.com> Date: Mon, 22 Sep 2025 13:15:30 -0400 Subject: [PATCH 13/99] import --- discord/ui/core.py | 1 + 1 file changed, 1 insertion(+) diff --git a/discord/ui/core.py b/discord/ui/core.py index 36a57ffba1..a2d34118a2 100644 --- a/discord/ui/core.py +++ b/discord/ui/core.py @@ -25,6 +25,7 @@ from __future__ import annotations import asyncio +import time from itertools import groupby from typing import TYPE_CHECKING, Any, Callable From c6c4fbff721374351f7136331ed9570a33a517fc Mon Sep 17 00:00:00 2001 From: UK <41271523+NeloBlivion@users.noreply.github.com> Date: Mon, 22 Sep 2025 13:17:18 -0400 Subject: [PATCH 14/99] ) --- discord/ui/view.py | 1 + 1 file changed, 1 insertion(+) diff --git a/discord/ui/view.py b/discord/ui/view.py index 3f84ac2c17..5f3e8e28b6 100644 --- a/discord/ui/view.py +++ b/discord/ui/view.py @@ -390,6 +390,7 @@ def _dispatch_item(self, item: Item[V], interaction: Interaction): asyncio.create_task( self._scheduled_task(item, interaction), name=f"discord-ui-view-dispatch-{self.id}", + ) def is_finished(self) -> bool: """Whether the view has finished interacting.""" From 57282b874d00d7f7a8902d1852103ccaa54eb7e6 Mon Sep 17 00:00:00 2001 From: UK <41271523+NeloBlivion@users.noreply.github.com> Date: Mon, 22 Sep 2025 13:20:59 -0400 Subject: [PATCH 15/99] )) --- discord/ui/modal.py | 301 +++++++++++++++++++++++++++++++++++++++++++- discord/ui/view.py | 1 - 2 files changed, 300 insertions(+), 2 deletions(-) diff --git a/discord/ui/modal.py b/discord/ui/modal.py index 32bf5853bc..0b3d77fefc 100644 --- a/discord/ui/modal.py +++ b/discord/ui/modal.py @@ -14,9 +14,12 @@ from .item import Item from .select import Select from .text_display import TextDisplay +from .core import ComponentUI __all__ = ( + "BaseModal", "Modal", + "DesignerModal", "ModalStore", ) @@ -31,8 +34,304 @@ ModalItem = Union[InputText, Item[M]] +class BaseModal(ComponentUI): + """Represents a UI Modal dialog. + + This object must be inherited to create a UI within Discord. + + .. versionadded:: 2.7 + + Parameters + ---------- + children: Union[:class:`Item`] + The initial items that are displayed in the modal dialog. + title: :class:`str` + The title of the modal dialog. + Must be 45 characters or fewer. + custom_id: Optional[:class:`str`] + The ID of the modal dialog 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. + """ + + __item_repr_attributes__: tuple[str, ...] = ( + "title", + "children", + "timeout", + ) + + def __init__( + self, + *children: ModalItem, + title: str, + custom_id: str | None = None, + timeout: float | None = None, + ) -> None: + self.timeout: float | None = timeout + 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._title = title + self._children: list[ModalItem] = list(children) + self._weights = _ModalWeights(self._children) + loop = asyncio.get_running_loop() + self._stopped: asyncio.Future[bool] = loop.create_future() + self.__cancel_callback: Callable[[Modal], None] | None = None + self.__timeout_expiry: float | None = None + self.__timeout_task: asyncio.Task[None] | None = None + self.loop = asyncio.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()) + + 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): + 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 dialog.""" + 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 components associated with the modal dialog.""" + return self._children + + @children.setter + def children(self, value: list[ModalItem]): + for item in value: + if not isinstance(item, (InputText, Item)): + raise TypeError( + "all Modal children must be InputText or Item, not" + f" {item.__class__.__name__}" + ) + self._weights = _ModalWeights(self._children) + self._children = value + + @property + def custom_id(self) -> str: + """The ID of the modal dialog 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 dialog is submitted. + Should be overridden to handle the values submitted by the user. + + Parameters + ---------- + interaction: :class:`~discord.Interaction` + The interaction that submitted the modal dialog. + """ + self.stop() + + def to_components(self) -> list[dict[str, Any]]: + def key(item: ModalItem) -> 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): + labels = False + toplevel = False + children = [] + for item in group: + if item.uses_label() or isinstance(item, Select): + labels = True + elif isinstance(item, (TextDisplay,)): + toplevel = True + children.append(item) + if not children: + continue + + 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, + } + ) + elif toplevel: + components += [item.to_component_dict() for item in children] + else: + components.append( + { + "type": 1, + "components": [item.to_component_dict() for item in children], + } + ) + + return components + + def add_item(self, item: ModalItem) -> Self: + """Adds a component to the modal dialog. + + Parameters + ---------- + item: Union[class:`InputText`, :class:`Item`] + 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, Item)): + raise TypeError(f"expected InputText or Item, not {item.__class__!r}") + if isinstance(item, (InputText, Select)) and not item.label: + raise ValueError("InputTexts and Selects must have a label set") + + self._weights.add_item(item) + self._children.append(item) + return self + + def remove_item(self, item: ModalItem) -> Self: + """Removes a component from the modal dialog. + + Parameters + ---------- + item: Union[class:`InputText`, :class:`Item`] + The item to remove from the modal dialog. + """ + try: + self._children.remove(item) + except ValueError: + pass + return self + + def get_item(self, id: str | int) -> ModalItem | None: + """Gets an item from the modal. Roughly equal to `utils.get(modal.children, ...)`. + If an :class:`int` is provided, the item will be retrieved by ``id``, otherwise by ``custom_id``. + + Parameters + ---------- + id: Union[:class:`int`, :class:`str`] + The id or custom_id of the item to get + + Returns + ------- + Optional[Union[class:`InputText`, :class:`Item`]] + The item with the matching ``custom_id`` or ``id`` if it exists. + """ + if not id: + return None + attr = "id" if isinstance(id, int) else "custom_id" + return find(lambda i: getattr(i, attr, None) == id, self.children) + + def stop(self) -> None: + """Stops listening to interaction events from the modal dialog.""" + 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 dialog 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. + """ -class Modal: +class Modal(Modal): """Represents a UI Modal dialog. This object must be inherited to create a UI within Discord. diff --git a/discord/ui/view.py b/discord/ui/view.py index 5f3e8e28b6..0ac26fd5a7 100644 --- a/discord/ui/view.py +++ b/discord/ui/view.py @@ -429,7 +429,6 @@ def stop(self) -> None: if self.__cancel_callback: self.__cancel_callback(self) self.__cancel_callback = None - ) async def wait(self) -> bool: """Waits until the view has finished interacting. From 1da73f610187fee8cd6e41762cd4fbdfb4daf8f6 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Mon, 22 Sep 2025 17:21:28 +0000 Subject: [PATCH 16/99] style(pre-commit): auto fixes from pre-commit.com hooks --- discord/ui/modal.py | 4 +++- discord/ui/view.py | 13 +++++++++++-- 2 files changed, 14 insertions(+), 3 deletions(-) diff --git a/discord/ui/modal.py b/discord/ui/modal.py index 0b3d77fefc..1537841d4f 100644 --- a/discord/ui/modal.py +++ b/discord/ui/modal.py @@ -10,11 +10,11 @@ from ..enums import ComponentType from ..utils import find +from .core import ComponentUI from .input_text import InputText from .item import Item from .select import Select from .text_display import TextDisplay -from .core import ComponentUI __all__ = ( "BaseModal", @@ -34,6 +34,7 @@ ModalItem = Union[InputText, Item[M]] + class BaseModal(ComponentUI): """Represents a UI Modal dialog. @@ -331,6 +332,7 @@ async def on_timeout(self) -> None: A callback that is called when a modal's timeout elapses without being explicitly stopped. """ + class Modal(Modal): """Represents a UI Modal dialog. diff --git a/discord/ui/view.py b/discord/ui/view.py index 0ac26fd5a7..e694f02044 100644 --- a/discord/ui/view.py +++ b/discord/ui/view.py @@ -31,7 +31,7 @@ import time from functools import partial from itertools import groupby -from typing import TYPE_CHECKING, Any, Callable, ClassVar, Iterator, Sequence, TypeVar +from typing import TYPE_CHECKING, Any, ClassVar, Iterator, Sequence, TypeVar from ..components import ActionRow as ActionRowComponent from ..components import Button as ButtonComponent @@ -50,7 +50,13 @@ from .core import ComponentUI from .item import Item, ItemCallbackType -__all__ = ("BaseView", "View", "DesignerView", "_component_to_item", "_walk_all_components") +__all__ = ( + "BaseView", + "View", + "DesignerView", + "_component_to_item", + "_walk_all_components", +) if TYPE_CHECKING: @@ -191,6 +197,7 @@ def fits_legacy(self, item) -> bool: return item.row <= 4 return self.weights[-1] + item.width <= 5 + class BaseView(ComponentUI): """The base class for UI views used in messages.""" @@ -501,6 +508,7 @@ def message(self): def message(self, value): self._message = value + class View(BaseView): """Represents a UI view for v1 components :class:`~discord.ui.Button` and :class:`~discord.ui.Select`. @@ -727,6 +735,7 @@ def refresh(self, components: list[Component]): def is_components_v2(self) -> bool: return False + class DesignerView(BaseView): """Represents a UI view compatible with v2 components. From 2b981d31f185df9bdf621c8c3d33bb99f124f484 Mon Sep 17 00:00:00 2001 From: UK <41271523+NeloBlivion@users.noreply.github.com> Date: Mon, 22 Sep 2025 15:05:42 -0400 Subject: [PATCH 17/99] attempt modal conversion --- discord/components.py | 8 +- discord/ui/action_row.py | 30 ++- discord/ui/core.py | 2 +- discord/ui/input_text.py | 17 +- discord/ui/item.py | 5 + discord/ui/label.py | 286 +++++++++++++++++++++++++ discord/ui/modal.py | 444 +++++++++------------------------------ discord/ui/view.py | 4 + 8 files changed, 418 insertions(+), 378 deletions(-) create mode 100644 discord/ui/label.py diff --git a/discord/components.py b/discord/components.py index ee5c1201ca..736e57bfb6 100644 --- a/discord/components.py +++ b/discord/components.py @@ -266,7 +266,7 @@ def __init__(self, data: InputTextComponentPayload): self.id: int | None = data.get("id") self.style: InputTextStyle = try_enum(InputTextStyle, data["style"]) self.custom_id = data["custom_id"] - self.label: str = data.get("label", None) + self.label: str | None = data.get("label", None) self.placeholder: str | None = data.get("placeholder", None) self.min_length: int | None = data.get("min_length", None) self.max_length: int | None = data.get("max_length", None) @@ -278,7 +278,6 @@ def to_dict(self) -> InputTextComponentPayload: "type": 4, "id": self.id, "style": self.style.value, - "label": self.label, } if self.custom_id: payload["custom_id"] = self.custom_id @@ -298,6 +297,9 @@ def to_dict(self) -> InputTextComponentPayload: if self.value: payload["value"] = self.value + if self.label: + payload["label"] = self.label + return payload # type: ignore @@ -1305,7 +1307,7 @@ class Label(Component): ``component`` may only be: - :class:`InputText` - - :class:`SelectMenu` (string) + - :class:`SelectMenu` This inherits from :class:`Component`. diff --git a/discord/ui/action_row.py b/discord/ui/action_row.py index cb340ef4ea..c6bb584bee 100644 --- a/discord/ui/action_row.py +++ b/discord/ui/action_row.py @@ -68,7 +68,7 @@ def __init__( ): super().__init__() - self.items: list[Item] = [] + self.children: list[Item] = [] self._underlying = ActionRowComponent._raw_construct( type=ComponentType.action_row, @@ -114,7 +114,7 @@ def add_item(self, item: Item) -> Self: item._view = self.view item.parent = self - self.items.append(item) + self.children.append(item) self._add_component_from_item(item) return self @@ -130,13 +130,13 @@ def remove_item(self, item: Item | str | int) -> Self: if isinstance(item, (str, int)): item = self.get_item(item) try: - self.items.remove(item) + self.children.remove(item) except ValueError: pass return self def get_item(self, id: str | int) -> Item | None: - """Get an item from this action row. Roughly equivalent to `utils.get(row.items, ...)`. + """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 @@ -152,7 +152,7 @@ def get_item(self, id: str | int) -> Item | None: 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) + child = find(lambda i: getattr(i, attr, None) == id, self.children) return child def add_button( @@ -268,7 +268,7 @@ def add_select( @Item.view.setter def view(self, value): self._view = value - for item in self.items: + for item in self.children: item.parent = self item._view = value @@ -276,21 +276,17 @@ def view(self, value): def type(self) -> ComponentType: return self._underlying.type - @property - def width(self) -> int: - return 5 - def is_dispatchable(self) -> bool: - return any(item.is_dispatchable() for item in self.items) + return any(item.is_dispatchable() for item in self.children) def is_persistent(self) -> bool: - return all(item.is_persistent() for item in self.items) + return all(item.is_persistent() for item in self.children) def refresh_component(self, component: ActionRowComponent) -> None: self._underlying = component i = 0 for y in component.components: - x = self.items[i] + x = self.children[i] x.refresh_component(y) i += 1 @@ -301,7 +297,7 @@ def disable_all_items(self, *, exclusions: list[Item] | None = None) -> Self: Parameters ---------- exclusions: Optional[List[:class:`Item`]] - A list of items in `self.items` to not disable. + 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: @@ -315,7 +311,7 @@ def enable_all_items(self, *, exclusions: list[Item] | None = None) -> Self: Parameters ---------- exclusions: Optional[List[:class:`Item`]] - A list of items in `self.items` to not enable. + A list of items in `self.children` to not enable. """ for item in self.walk_items(): if hasattr(item, "disabled") and ( @@ -325,10 +321,10 @@ def enable_all_items(self, *, exclusions: list[Item] | None = None) -> Self: return self def walk_items(self) -> Iterator[Item]: - yield from self.items + yield from self.children def to_component_dict(self) -> ActionRowPayload: - self._set_components(self.items) + self._set_components(self.children) return self._underlying.to_dict() @classmethod diff --git a/discord/ui/core.py b/discord/ui/core.py index a2d34118a2..0571505fe6 100644 --- a/discord/ui/core.py +++ b/discord/ui/core.py @@ -86,7 +86,7 @@ def __init__( *items: Item, timeout: float | None = 180.0, ): - self.timeout = timeout + self.timeout: float | None = timeout self.children: list[Item] = [] for item in items: self.add_item(item) diff --git a/discord/ui/input_text.py b/discord/ui/input_text.py index 4f7828ce97..3b350bd9c3 100644 --- a/discord/ui/input_text.py +++ b/discord/ui/input_text.py @@ -27,11 +27,6 @@ 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. @@ -64,7 +59,6 @@ class InputText: "max_length", "custom_id", "id", - "description", ) def __init__( @@ -72,7 +66,7 @@ def __init__( *, style: InputTextStyle = InputTextStyle.short, custom_id: str | None = None, - label: str, + label: str | None = None, placeholder: str | None = None, min_length: int | None = None, max_length: int | None = None, @@ -80,13 +74,10 @@ 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: + if label and 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): @@ -100,7 +91,6 @@ 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, @@ -251,8 +241,5 @@ def refresh_state(self, data) -> None: def refresh_from_modal(self, interaction: Interaction, data: dict) -> None: return self.refresh_state(data) - def uses_label(self) -> bool: - return self.description is not None - TextInput = InputText diff --git a/discord/ui/item.py b/discord/ui/item.py index e2a568e345..96d6014afe 100644 --- a/discord/ui/item.py +++ b/discord/ui/item.py @@ -196,6 +196,11 @@ def view(self) -> V | None: The parent view of this item, or ``None`` if the item is not attached to any view. """ return self._view + + @view.setter + def view(self, value) -> None: + self._view = value + async def callback(self, interaction: Interaction): """|coro| diff --git a/discord/ui/label.py b/discord/ui/label.py new file mode 100644 index 0000000000..173cfdf6f7 --- /dev/null +++ b/discord/ui/label.py @@ -0,0 +1,286 @@ +from __future__ import annotations + +from functools import partial +from typing import TYPE_CHECKING, ClassVar, Iterator, TypeVar + +from ..components import Label as LabelComponent +from ..components import SelectOption, _component_factory +from ..enums import ButtonStyle, ChannelType, ComponentType, InputTextStyle +from ..utils import find, get +from .button import Button +from .item import Item, ItemCallbackType +from .select import Select +from .input_text import InputText + +__all__ = ("Label",) + +if TYPE_CHECKING: + from typing_extensions import Self + + from ..emoji import AppEmoji, GuildEmoji + from ..partial_emoji import PartialEmoji, _EmojiTag + from ..types.components import Label as LabelPayload + from .view import View + + +L = TypeVar("L", bound="Label") +V = TypeVar("V", bound="View", covariant=True) + + +class Label(Item[V]): + """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` + + .. versionadded:: 2.7 + + Parameters + ---------- + item: :class:`Item` + The initial item in 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. + """ + + __item_repr_attributes__: tuple[str, ...] = ( + "item", + "id", + "label", + "description", + ) + + def __init__( + self, + item: Item, + *, + label: str, + description: str | None = None, + id: int | None = None, + ): + super().__init__() + + self.item: Item = None + + self._underlying = LabelComponent._raw_construct( + type=ComponentType.label, + id=id, + component=None, + label=label, + description=description, + ) + + self.set_item(item) + + def _set_component_from_item(self, item: Item): + self._underlying.component = item._underlying + + def set_item(self, item: Item) -> Self: + """Set this label's item. + + Parameters + ---------- + item: :class:`Item` + The item to set. + Currently only supports :class:`~discord.ui.Select` and :class:`~discord.ui.InputText`. + + Raises + ------ + TypeError + An :class:`Item` was not passed. + """ + + if not isinstance(item, Item): + raise TypeError(f"expected Item not {item.__class__!r}") + if isinstance(item, InputText) and item.label: + raise ValueError(f"InputText.label cannot be set inside Label") + if self.view: + item._view = self.view + item.parent = self + + self.item = item + self._set_component_from_item(item) + return self + + def get_item(self, id: str | int) -> Item | 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:`Item`] + 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) + + 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, + disabled: bool = False, + id: int | 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`. + disabled: :class:`bool` + Whether the select is disabled or not. Defaults to ``False``. + id: Optional[:class:`int`] + The select menu's ID. + """ + + 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, + ) + + return self.set_item(select) + + @Item.view.setter + def view(self, value): + self._view = value + self.item.parent = self + self.item._view = value + + @property + def type(self) -> ComponentType: + return self._underlying.type + + 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[Item]: + yield from [self.item] + + def to_component_dict(self) -> LabelPayload: + self._set_component_from_item(self.item) + return self._underlying.to_dict() + + @classmethod + def from_component(cls: type[L], component: LabelComponent) -> L: + from .view import _component_to_item + + item = _component_to_item(component.component) + return cls( + item, + id=component.id, + label=component.label, + description=component.description + ) + + callback = None diff --git a/discord/ui/modal.py b/discord/ui/modal.py index 1537841d4f..cde39ecf7a 100644 --- a/discord/ui/modal.py +++ b/discord/ui/modal.py @@ -13,6 +13,7 @@ from .core import ComponentUI from .input_text import InputText from .item import Item +from .label import Label from .select import Select from .text_display import TextDisplay @@ -32,11 +33,11 @@ M = TypeVar("M", bound="Modal", covariant=True) -ModalItem = Union[InputText, Item[M]] +ModalItem = Union[Item[M]] class BaseModal(ComponentUI): - """Represents a UI Modal dialog. + """Represents a UI modal. This object must be inherited to create a UI within Discord. @@ -45,12 +46,12 @@ class BaseModal(ComponentUI): Parameters ---------- children: Union[:class:`Item`] - The initial items that are displayed in the modal dialog. + The initial items that are displayed in the modal. title: :class:`str` - The title of the modal dialog. + The title of the modal. Must be 45 characters or fewer. custom_id: Optional[:class:`str`] - The ID of the modal dialog that gets received during an interaction. + 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. @@ -70,7 +71,7 @@ def __init__( custom_id: str | None = None, timeout: float | None = None, ) -> None: - self.timeout: float | None = timeout + self._children: list[ModalItem] = list(children) 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__}" @@ -78,14 +79,9 @@ def __init__( 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") + super().__init__(*children, timeout=timeout) self._title = title - self._children: list[ModalItem] = list(children) self._weights = _ModalWeights(self._children) - loop = asyncio.get_running_loop() - self._stopped: asyncio.Future[bool] = loop.create_future() - self.__cancel_callback: Callable[[Modal], None] | None = None - self.__timeout_expiry: float | None = None - self.__timeout_task: asyncio.Task[None] | None = None self.loop = asyncio.get_event_loop() def __repr__(self) -> str: @@ -104,29 +100,6 @@ def _start_listening_from_store(self, store: ModalStore) -> None: self.__timeout_expiry = time.monotonic() + self.timeout self.__timeout_task = loop.create_task(self.__timeout_task_impl()) - 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): if self._stopped.done(): return @@ -138,7 +111,7 @@ def _dispatch_timeout(self): @property def title(self) -> str: - """The title of the modal dialog.""" + """The title of the modal.""" return self._title @title.setter @@ -151,7 +124,7 @@ def title(self, value: str): @property def children(self) -> list[ModalItem]: - """The child components associated with the modal dialog.""" + """The child components associated with the modal.""" return self._children @children.setter @@ -162,12 +135,11 @@ def children(self, value: list[ModalItem]): "all Modal children must be InputText or Item, not" f" {item.__class__.__name__}" ) - self._weights = _ModalWeights(self._children) self._children = value @property def custom_id(self) -> str: - """The ID of the modal dialog that gets received during an interaction.""" + """The ID of the modal that gets received during an interaction.""" return self._custom_id @custom_id.setter @@ -183,87 +155,41 @@ def custom_id(self, value: str): async def callback(self, interaction: Interaction): """|coro| - The coroutine that is called when the modal dialog is submitted. + 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 dialog. + The interaction that submitted the modal. """ self.stop() - def to_components(self) -> list[dict[str, Any]]: - def key(item: ModalItem) -> 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): - labels = False - toplevel = False - children = [] - for item in group: - if item.uses_label() or isinstance(item, Select): - labels = True - elif isinstance(item, (TextDisplay,)): - toplevel = True - children.append(item) - if not children: - continue - - 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, - } - ) - elif toplevel: - components += [item.to_component_dict() for item in children] - else: - components.append( - { - "type": 1, - "components": [item.to_component_dict() for item in children], - } - ) - - return components - def add_item(self, item: ModalItem) -> Self: - """Adds a component to the modal dialog. + """Adds a component to the modal. Parameters ---------- item: Union[class:`InputText`, :class:`Item`] - The item to add to the modal dialog + 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 dialog.") + raise ValueError("You can only have up to 5 items in a modal.") - if not isinstance(item, (InputText, Item)): - raise TypeError(f"expected InputText or Item, not {item.__class__!r}") - if isinstance(item, (InputText, Select)) and not item.label: - raise ValueError("InputTexts and Selects must have a label set") + if not isinstance(item, (Item, )): + raise TypeError(f"expected Item, not {item.__class__!r}") - self._weights.add_item(item) self._children.append(item) return self def remove_item(self, item: ModalItem) -> Self: - """Removes a component from the modal dialog. + """Removes a component from the modal. Parameters ---------- item: Union[class:`InputText`, :class:`Item`] - The item to remove from the modal dialog. + The item to remove from the modal. """ try: self._children.remove(item) @@ -271,27 +197,8 @@ def remove_item(self, item: ModalItem) -> Self: pass return self - def get_item(self, id: str | int) -> ModalItem | None: - """Gets an item from the modal. Roughly equal to `utils.get(modal.children, ...)`. - If an :class:`int` is provided, the item will be retrieved by ``id``, otherwise by ``custom_id``. - - Parameters - ---------- - id: Union[:class:`int`, :class:`str`] - The id or custom_id of the item to get - - Returns - ------- - Optional[Union[class:`InputText`, :class:`Item`]] - The item with the matching ``custom_id`` or ``id`` if it exists. - """ - if not id: - return None - attr = "id" if isinstance(id, int) else "custom_id" - return find(lambda i: getattr(i, attr, None) == id, self.children) - def stop(self) -> None: - """Stops listening to interaction events from the modal dialog.""" + """Stops listening to interaction events from the modal.""" if not self._stopped.done(): self._stopped.set_result(True) self.__timeout_expiry = None @@ -300,7 +207,7 @@ def stop(self) -> None: self.__timeout_task = None async def wait(self) -> bool: - """Waits for the modal dialog to be submitted.""" + """Waits for the modal to be submitted.""" return await self._stopped def to_dict(self): @@ -332,9 +239,8 @@ async def on_timeout(self) -> None: A callback that is called when a modal's timeout elapses without being explicitly stopped. """ - -class Modal(Modal): - """Represents a UI Modal dialog. +class Modal(BaseModal): + """Represents a UI modal for InputText components. This object must be inherited to create a UI within Discord. @@ -342,29 +248,23 @@ class Modal(Modal): .. versionchanged:: 2.7 - :class:`discord.ui.Select` and :class:`discord.ui.TextDisplay` can now be used in modals. + Now inherits from :class:`BaseModal` Parameters ---------- - children: Union[:class:`InputText`, :class:`Item`] - The initial items that are displayed in the modal dialog. Currently supports :class:`discord.ui.Select` and :class:`discord.ui.TextDisplay`. + 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 dialog. + The title of the modal. Must be 45 characters or fewer. custom_id: Optional[:class:`str`] - The ID of the modal dialog that gets received during an interaction. + 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. """ - __item_repr_attributes__: tuple[str, ...] = ( - "title", - "children", - "timeout", - ) - def __init__( self, *children: ModalItem, @@ -372,273 +272,133 @@ def __init__( custom_id: str | None = None, timeout: float | None = None, ) -> None: - self.timeout: float | None = timeout - 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._title = title - self._children: list[ModalItem] = list(children) + super().__init__(*children, title=title, custom_id=custom_id, timeout=timeout) self._weights = _ModalWeights(self._children) - loop = asyncio.get_running_loop() - self._stopped: asyncio.Future[bool] = loop.create_future() - self.__cancel_callback: Callable[[Modal], None] | None = None - self.__timeout_expiry: float | None = None - self.__timeout_task: asyncio.Task[None] | None = None - self.loop = asyncio.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()) - - 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): - 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 dialog.""" - 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 components associated with the modal dialog.""" - return self._children @children.setter def children(self, value: list[ModalItem]): for item in value: - if not isinstance(item, (InputText, Item)): + if not isinstance(item, InputText): raise TypeError( - "all Modal children must be InputText or Item, not" + "all Modal children must be InputText, not" f" {item.__class__.__name__}" ) self._weights = _ModalWeights(self._children) self._children = value - @property - def custom_id(self) -> str: - """The ID of the modal dialog 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 dialog is submitted. - Should be overridden to handle the values submitted by the user. - - Parameters - ---------- - interaction: :class:`~discord.Interaction` - The interaction that submitted the modal dialog. - """ - self.stop() - def to_components(self) -> list[dict[str, Any]]: - def key(item: ModalItem) -> int: + 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): - labels = False - toplevel = False - children = [] - for item in group: - if item.uses_label() or isinstance(item, Select): - labels = True - elif isinstance(item, (TextDisplay,)): - toplevel = True - children.append(item) + children = [item.to_component_dict() for item in group] if not children: continue - 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, - } - ) - elif toplevel: - components += [item.to_component_dict() for item in children] - else: - components.append( - { - "type": 1, - "components": [item.to_component_dict() for item in children], - } - ) + components.append( + { + "type": 1, + "components": children, + } + ) return components - def add_item(self, item: ModalItem) -> Self: - """Adds a component to the modal dialog. + def add_item(self, item: InputText) -> Self: + """Adds an InputText component to the modal. Parameters ---------- - item: Union[class:`InputText`, :class:`Item`] - The item to add to the modal dialog + item: :class:`InputText` + 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 dialog.") - - if not isinstance(item, (InputText, Item)): - raise TypeError(f"expected InputText or Item, not {item.__class__!r}") - if isinstance(item, (InputText, Select)) and not item.label: - raise ValueError("InputTexts and Selects must have a label set") + if not isinstance(item, InputText): + raise TypeError(f"expected InputText not {item.__class__!r}") self._weights.add_item(item) - self._children.append(item) + super().add_item(item) return self def remove_item(self, item: ModalItem) -> Self: - """Removes a component from the modal dialog. + """Removes a component from the modal. Parameters ---------- item: Union[class:`InputText`, :class:`Item`] - The item to remove from the modal dialog. + The item to remove from the modal. """ + + super().remove_item(item) try: - self._children.remove(item) + self.__weights.remove_item(item) except ValueError: pass return self - def get_item(self, id: str | int) -> ModalItem | None: - """Gets an item from the modal. Roughly equal to `utils.get(modal.children, ...)`. - If an :class:`int` is provided, the item will be retrieved by ``id``, otherwise by ``custom_id``. - - Parameters - ---------- - id: Union[:class:`int`, :class:`str`] - The id or custom_id of the item to get - - Returns - ------- - Optional[Union[class:`InputText`, :class:`Item`]] - The item with the matching ``custom_id`` or ``id`` if it exists. - """ - if not id: - return None - attr = "id" if isinstance(id, int) else "custom_id" - return find(lambda i: getattr(i, attr, None) == id, self.children) +class DesignerModal(BaseModal): + """Represents a UI modal compatible with all modal features. - def stop(self) -> None: - """Stops listening to interaction events from the modal dialog.""" - 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 + This object must be inherited to create a UI within Discord. - async def wait(self) -> bool: - """Waits for the modal dialog to be submitted.""" - return await self._stopped + .. versionadded:: 2.7 - def to_dict(self): - return { - "title": self.title, - "custom_id": self.custom_id, - "components": self.to_components(), - } + Parameters + ---------- + children: Union[:class:`Item`] + 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. + """ - async def on_error(self, error: Exception, interaction: Interaction) -> None: - """|coro| + def __init__( + self, + *children: ModalItem, + title: str, + custom_id: str | None = None, + timeout: float | None = None, + ) -> None: + super().__init__(*children, title=title, custom_id=custom_id, timeout=timeout) - A callback that is called when the modal's callback fails with an error. + @children.setter + def children(self, value: list[ModalItem]): + for item in value: + if not isinstance(item, Item): + raise TypeError( + "all DesignerModal children must be Item, not" + f" {item.__class__.__name__}" + ) + self._children = value - The default implementation prints the traceback to stderr. + def add_item(self, item: ModalItem) -> Self: + """Adds a component to the modal. Parameters ---------- - error: :class:`Exception` - The exception that was raised. - interaction: :class:`~discord.Interaction` - The interaction that led to the failure. + item: Union[:class:`Item`] + The item to add to the modal """ - interaction.client.dispatch("modal_error", error, interaction) - async def on_timeout(self) -> None: - """|coro| + if isinstance(item, (InputText, )): + raise TypeError(f"DesignerModal does not accept InputText directly. Use Label instead.") - A callback that is called when a modal's timeout elapses without being explicitly stopped. - """ + super().add_item(item) + return self class _ModalWeights: __slots__ = ("weights",) - def __init__(self, children: list[ModalItem]): + 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 @@ -647,14 +407,14 @@ def __init__(self, children: list[ModalItem]): for item in group: self.add_item(item) - def find_open_space(self, item: ModalItem) -> int: + 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: ModalItem) -> None: + def add_item(self, item: InputText) -> None: if item.row is not None: total = self.weights[item.row] + item.width if total > 5: @@ -668,7 +428,7 @@ def add_item(self, item: ModalItem) -> None: self.weights[index] += item.width item._rendered_row = index - def remove_item(self, item: ModalItem) -> None: + 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 @@ -680,14 +440,14 @@ def clear(self) -> None: class ModalStore: def __init__(self, state: ConnectionState) -> None: # (user_id, custom_id) : Modal - self._modals: dict[tuple[int, str], Modal] = {} + self._modals: dict[tuple[int, str], BaseModal] = {} self._state: ConnectionState = state - def add_modal(self, modal: Modal, user_id: int): + def add_modal(self, modal: BaseModal, user_id: int): self._modals[(user_id, modal.custom_id)] = modal modal._start_listening_from_store(self) - def remove_modal(self, modal: Modal, user_id): + def remove_modal(self, modal: BaseModal, user_id): modal.stop() self._modals.pop((user_id, modal.custom_id)) diff --git a/discord/ui/view.py b/discord/ui/view.py index e694f02044..9aa842c151 100644 --- a/discord/ui/view.py +++ b/discord/ui/view.py @@ -516,6 +516,10 @@ class View(BaseView): .. versionadded:: 2.0 + .. versionchanged:: 2.7 + + Now inherits from :class:`BaseView` + Parameters ---------- *items: :class:`Item` From c929c6783cd3d83aaab44850a03bea97c47bb97f Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Mon, 22 Sep 2025 19:06:11 +0000 Subject: [PATCH 18/99] style(pre-commit): auto fixes from pre-commit.com hooks --- discord/ui/item.py | 3 +-- discord/ui/label.py | 7 +++---- discord/ui/modal.py | 14 +++++++++----- 3 files changed, 13 insertions(+), 11 deletions(-) diff --git a/discord/ui/item.py b/discord/ui/item.py index 96d6014afe..d80128a0c0 100644 --- a/discord/ui/item.py +++ b/discord/ui/item.py @@ -196,11 +196,10 @@ def view(self) -> V | None: The parent view of this item, or ``None`` if the item is not attached to any view. """ return self._view - + @view.setter def view(self, value) -> None: self._view = value - async def callback(self, interaction: Interaction): """|coro| diff --git a/discord/ui/label.py b/discord/ui/label.py index 173cfdf6f7..c93689288c 100644 --- a/discord/ui/label.py +++ b/discord/ui/label.py @@ -1,16 +1,15 @@ from __future__ import annotations -from functools import partial -from typing import TYPE_CHECKING, ClassVar, Iterator, TypeVar +from typing import TYPE_CHECKING, Iterator, TypeVar from ..components import Label as LabelComponent from ..components import SelectOption, _component_factory from ..enums import ButtonStyle, ChannelType, ComponentType, InputTextStyle from ..utils import find, get from .button import Button +from .input_text import InputText from .item import Item, ItemCallbackType from .select import Select -from .input_text import InputText __all__ = ("Label",) @@ -280,7 +279,7 @@ def from_component(cls: type[L], component: LabelComponent) -> L: item, id=component.id, label=component.label, - description=component.description + description=component.description, ) callback = None diff --git a/discord/ui/modal.py b/discord/ui/modal.py index cde39ecf7a..4ce08e6c9d 100644 --- a/discord/ui/modal.py +++ b/discord/ui/modal.py @@ -6,7 +6,7 @@ import time from functools import partial from itertools import groupby -from typing import TYPE_CHECKING, Any, Callable, TypeVar, Union +from typing import TYPE_CHECKING, Any, TypeVar, Union from ..enums import ComponentType from ..utils import find @@ -171,13 +171,13 @@ def add_item(self, item: ModalItem) -> Self: Parameters ---------- item: Union[class:`InputText`, :class:`Item`] - The item to add to the modal + 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, (Item, )): + if not isinstance(item, (Item,)): raise TypeError(f"expected Item, not {item.__class__!r}") self._children.append(item) @@ -239,6 +239,7 @@ async def on_timeout(self) -> None: A callback that is called when a modal's timeout elapses without being explicitly stopped. """ + class Modal(BaseModal): """Represents a UI modal for InputText components. @@ -338,6 +339,7 @@ def remove_item(self, item: ModalItem) -> Self: pass return self + class DesignerModal(BaseModal): """Represents a UI modal compatible with all modal features. @@ -388,8 +390,10 @@ def add_item(self, item: ModalItem) -> Self: The item to add to the modal """ - if isinstance(item, (InputText, )): - raise TypeError(f"DesignerModal does not accept InputText directly. Use Label instead.") + if isinstance(item, (InputText,)): + raise TypeError( + f"DesignerModal does not accept InputText directly. Use Label instead." + ) super().add_item(item) return self From e61064d34dcac0d2b1bf698ab551c7daa2aed8aa Mon Sep 17 00:00:00 2001 From: UK <41271523+NeloBlivion@users.noreply.github.com> Date: Mon, 22 Sep 2025 15:21:41 -0400 Subject: [PATCH 19/99] types --- discord/ui/core.py | 4 ++-- discord/ui/modal.py | 26 +++++++++++++++++--------- discord/ui/view.py | 6 +++--- 3 files changed, 22 insertions(+), 14 deletions(-) diff --git a/discord/ui/core.py b/discord/ui/core.py index 0571505fe6..a9f50b2aed 100644 --- a/discord/ui/core.py +++ b/discord/ui/core.py @@ -47,7 +47,7 @@ from .item import Item, ItemCallbackType from .view import View -__all__ = "ComponentUI" +__all__ = "ItemInterface" if TYPE_CHECKING: @@ -59,7 +59,7 @@ from ..types.components import Component as ComponentPayload -class ComponentUI: +class ItemInterface: """The base structure for classes that contain :class:`~discord.ui.Item`. .. versionadded:: 2.7 diff --git a/discord/ui/modal.py b/discord/ui/modal.py index 4ce08e6c9d..2af763e37c 100644 --- a/discord/ui/modal.py +++ b/discord/ui/modal.py @@ -10,7 +10,7 @@ from ..enums import ComponentType from ..utils import find -from .core import ComponentUI +from .core import ItemInterface from .input_text import InputText from .item import Item from .label import Label @@ -36,7 +36,7 @@ ModalItem = Union[Item[M]] -class BaseModal(ComponentUI): +class BaseModal(ItemInterface): """Represents a UI modal. This object must be inherited to create a UI within Discord. @@ -130,9 +130,9 @@ def children(self) -> list[ModalItem]: @children.setter def children(self, value: list[ModalItem]): for item in value: - if not isinstance(item, (InputText, Item)): + if not isinstance(item, Item): raise TypeError( - "all Modal children must be InputText or Item, not" + "all BaseModal children must be Item, not" f" {item.__class__.__name__}" ) self._children = value @@ -268,7 +268,7 @@ class Modal(BaseModal): def __init__( self, - *children: ModalItem, + *children: InputText, title: str, custom_id: str | None = None, timeout: float | None = None, @@ -276,8 +276,12 @@ def __init__( super().__init__(*children, title=title, custom_id=custom_id, timeout=timeout) self._weights = _ModalWeights(self._children) + @property + def children(self) -> list[InputText]: + return self._children + @children.setter - def children(self, value: list[ModalItem]): + def children(self, value: list[InputText]): for item in value: if not isinstance(item, InputText): raise TypeError( @@ -323,12 +327,12 @@ def add_item(self, item: InputText) -> Self: super().add_item(item) return self - def remove_item(self, item: ModalItem) -> Self: - """Removes a component from the modal. + def remove_item(self, item: InputText) -> Self: + """Removes an InputText from the modal. Parameters ---------- - item: Union[class:`InputText`, :class:`Item`] + item: Union[class:`InputText`] The item to remove from the modal. """ @@ -371,6 +375,10 @@ def __init__( ) -> None: super().__init__(*children, title=title, custom_id=custom_id, timeout=timeout) + @property + def children(self) -> list[ModalItem]: + return self._children + @children.setter def children(self, value: list[ModalItem]): for item in value: diff --git a/discord/ui/view.py b/discord/ui/view.py index 9aa842c151..4068a0b71a 100644 --- a/discord/ui/view.py +++ b/discord/ui/view.py @@ -31,7 +31,7 @@ import time from functools import partial from itertools import groupby -from typing import TYPE_CHECKING, Any, ClassVar, Iterator, Sequence, TypeVar +from typing import TYPE_CHECKING, Any, ClassVar, Iterator, Sequence, TypeVar, Self from ..components import ActionRow as ActionRowComponent from ..components import Button as ButtonComponent @@ -47,7 +47,7 @@ from ..components import Thumbnail as ThumbnailComponent from ..components import _component_factory from ..utils import find -from .core import ComponentUI +from .core import ItemInterface from .item import Item, ItemCallbackType __all__ = ( @@ -198,7 +198,7 @@ def fits_legacy(self, item) -> bool: return self.weights[-1] + item.width <= 5 -class BaseView(ComponentUI): +class BaseView(ItemInterface): """The base class for UI views used in messages.""" MAX_ITEMS: int From d8e3d4fb38af30da668ca734dface97fdef1b0ec Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Mon, 22 Sep 2025 19:22:07 +0000 Subject: [PATCH 20/99] style(pre-commit): auto fixes from pre-commit.com hooks --- discord/ui/view.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/discord/ui/view.py b/discord/ui/view.py index 4068a0b71a..352bfe1502 100644 --- a/discord/ui/view.py +++ b/discord/ui/view.py @@ -31,7 +31,7 @@ import time from functools import partial from itertools import groupby -from typing import TYPE_CHECKING, Any, ClassVar, Iterator, Sequence, TypeVar, Self +from typing import TYPE_CHECKING, Any, ClassVar, Iterator, Self, Sequence, TypeVar from ..components import ActionRow as ActionRowComponent from ..components import Button as ButtonComponent From 057a26baee5eb70362886330c3f6e8c2be396d9e Mon Sep 17 00:00:00 2001 From: UK <41271523+NeloBlivion@users.noreply.github.com> Date: Mon, 22 Sep 2025 15:40:12 -0400 Subject: [PATCH 21/99] container uses actionrow instead of button/select, remove decorator support --- discord/ui/container.py | 83 ++++++++++++++++------------------------- 1 file changed, 33 insertions(+), 50 deletions(-) diff --git a/discord/ui/container.py b/discord/ui/container.py index e18b0ec0d2..e52053b653 100644 --- a/discord/ui/container.py +++ b/discord/ui/container.py @@ -9,6 +9,7 @@ from ..components import _component_factory from ..enums import ComponentType, SeparatorSpacingSize from ..utils import find, get +from .action_row import ActionRow from .file import File from .item import Item, ItemCallbackType from .media_gallery import MediaGallery @@ -35,8 +36,7 @@ class Container(Item[V]): The current items supported are as follows: - - :class:`discord.ui.Button` - - :class:`discord.ui.Select` + - :class:`discord.ui.ActionRow` - :class:`discord.ui.Section` - :class:`discord.ui.TextDisplay` - :class:`discord.ui.MediaGallery` @@ -64,17 +64,6 @@ class Container(Item[V]): "id", ) - __container_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.__container_children_items__ = children - def __init__( self, *items: Item, @@ -95,34 +84,11 @@ def __init__( spoiler=spoiler, ) self.color = colour or color - - for func in self.__container_children_items__: - item: Item = 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) def _add_component_from_item(self, item: Item): - if item._underlying.is_v2(): - self._underlying.components.append(item._underlying) - else: - found = False - for row in reversed(self._underlying.components): - if ( - isinstance(row, ActionRow) and row.width + item.width <= 5 - ): # If a valid ActionRow exists - row.children.append(item._underlying) - found = True - elif not isinstance(row, ActionRow): - # create new row if last component is v2 - break - if not found: - row = ActionRow.with_components(item._underlying) - self._underlying.components.append(row) + self._underlying.components.append(item._underlying) def _set_components(self, items: list[Item]): self._underlying.components.clear() @@ -146,6 +112,9 @@ def add_item(self, item: Item) -> Self: if not isinstance(item, Item): raise TypeError(f"expected Item not {item.__class__!r}") + if isinstance(item, (Button, Select)): + raise TypeError(f"{item.__class__!r} cannot be added directly. Use ActionRow instead.") + item._view = self.view if hasattr(item, "items"): item.view = self @@ -167,7 +136,10 @@ def remove_item(self, item: Item | str | int) -> Self: if isinstance(item, (str, int)): item = self.get_item(item) try: - self.items.remove(item) + if isinstance(item, Container): + self.items.remove(item) + else: + item.parent.remove_item(item) except ValueError: pass return self @@ -198,6 +170,27 @@ def get_item(self, id: str | int) -> Item | None: return child return child + def add_row( + self, + *items: Item, + 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. + """ + + a = ActionRow(*items, id=id) + + return self.add_item(a) + def add_section( self, *items: Item, @@ -340,17 +333,13 @@ def view(self, value): for item in self.items: item.parent = self item._view = value - if hasattr(item, "items"): + if hasattr(item, "items") or hasattr(item, "children"): item.view = value @property def type(self) -> ComponentType: return self._underlying.type - @property - def width(self) -> int: - return 5 - def is_dispatchable(self) -> bool: return any(item.is_dispatchable() for item in self.items) @@ -360,13 +349,7 @@ def is_persistent(self) -> bool: def refresh_component(self, component: ContainerComponent) -> None: self._underlying = component i = 0 - flattened = [] - for c in component.components: - if isinstance(c, ActionRow): - flattened += c.children - else: - flattened.append(c) - for y in flattened: + for y in component.components: x = self.items[i] x.refresh_component(y) i += 1 From b7a8f6bb35f1e624daf76d7ed3abe180f02bbdc5 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Mon, 22 Sep 2025 19:40:40 +0000 Subject: [PATCH 22/99] style(pre-commit): auto fixes from pre-commit.com hooks --- discord/ui/container.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/discord/ui/container.py b/discord/ui/container.py index e52053b653..58a9ff63da 100644 --- a/discord/ui/container.py +++ b/discord/ui/container.py @@ -1,7 +1,6 @@ from __future__ import annotations -from functools import partial -from typing import TYPE_CHECKING, ClassVar, Iterator, TypeVar +from typing import TYPE_CHECKING, Iterator, TypeVar from ..colour import Colour from ..components import ActionRow @@ -113,7 +112,9 @@ def add_item(self, item: Item) -> Self: raise TypeError(f"expected Item not {item.__class__!r}") if isinstance(item, (Button, Select)): - raise TypeError(f"{item.__class__!r} cannot be added directly. Use ActionRow instead.") + raise TypeError( + f"{item.__class__!r} cannot be added directly. Use ActionRow instead." + ) item._view = self.view if hasattr(item, "items"): From 166f540dab6b9c0fc42960d5dd067849dd70b6c3 Mon Sep 17 00:00:00 2001 From: UK <41271523+NeloBlivion@users.noreply.github.com> Date: Mon, 22 Sep 2025 15:43:58 -0400 Subject: [PATCH 23/99] types --- discord/ui/container.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/discord/ui/container.py b/discord/ui/container.py index 58a9ff63da..f7bcdcc0f8 100644 --- a/discord/ui/container.py +++ b/discord/ui/container.py @@ -9,6 +9,8 @@ from ..enums import ComponentType, SeparatorSpacingSize from ..utils import find, get from .action_row import ActionRow +from .button import Button +from .select import Select from .file import File from .item import Item, ItemCallbackType from .media_gallery import MediaGallery From a7346cdc4a7f17e97125c4f51fee1ca61b012fd7 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Mon, 22 Sep 2025 19:44:25 +0000 Subject: [PATCH 24/99] style(pre-commit): auto fixes from pre-commit.com hooks --- discord/ui/container.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/discord/ui/container.py b/discord/ui/container.py index f7bcdcc0f8..be047c88b6 100644 --- a/discord/ui/container.py +++ b/discord/ui/container.py @@ -10,11 +10,11 @@ from ..utils import find, get from .action_row import ActionRow from .button import Button -from .select import Select from .file import File from .item import Item, ItemCallbackType from .media_gallery import MediaGallery from .section import Section +from .select import Select from .separator import Separator from .text_display import TextDisplay from .view import _walk_all_components From 836998d7a8fae3b98e25cc2077a19ca722a5d09a Mon Sep 17 00:00:00 2001 From: UK <41271523+NeloBlivion@users.noreply.github.com> Date: Mon, 22 Sep 2025 16:17:32 -0400 Subject: [PATCH 25/99] imports --- discord/ui/__init__.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/discord/ui/__init__.py b/discord/ui/__init__.py index 473ac45563..a909f35b42 100644 --- a/discord/ui/__init__.py +++ b/discord/ui/__init__.py @@ -8,11 +8,14 @@ :license: MIT, see LICENSE for more details. """ +from .action_row import * from .button import * +from .core import * from .container import * from .file import * from .input_text import * from .item import * +from .label import * from .media_gallery import * from .modal import * from .section import * From 16b48860b975bfcc8aa1c7d94d008e9811d6d538 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Mon, 22 Sep 2025 20:17:58 +0000 Subject: [PATCH 26/99] style(pre-commit): auto fixes from pre-commit.com hooks --- discord/ui/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/discord/ui/__init__.py b/discord/ui/__init__.py index a909f35b42..37808cf427 100644 --- a/discord/ui/__init__.py +++ b/discord/ui/__init__.py @@ -10,8 +10,8 @@ from .action_row import * from .button import * -from .core import * from .container import * +from .core import * from .file import * from .input_text import * from .item import * From 5255da109af42ddc4aaf141b6600a07d2ca08419 Mon Sep 17 00:00:00 2001 From: UK <41271523+NeloBlivion@users.noreply.github.com> Date: Mon, 22 Sep 2025 16:21:55 -0400 Subject: [PATCH 27/99] remove Select.label & Select.description --- discord/ui/select.py | 32 +------------------------------- 1 file changed, 1 insertion(+), 31 deletions(-) diff --git a/discord/ui/select.py b/discord/ui/select.py index a779016b4e..e6a6617cdf 100644 --- a/discord/ui/select.py +++ b/discord/ui/select.py @@ -97,7 +97,7 @@ class Select(Generic[V, ST], Item[V]): .. versionchanged:: 2.7 - Can now be sent in :class:`discord.ui.Modal`. + Can now be sent in :class:`discord.ui.DesignerModal`. Parameters ---------- @@ -137,16 +137,6 @@ class Select(Generic[V, ST], 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. @@ -194,8 +184,6 @@ class Select(Generic[V, ST], Item[V]): "disabled", "custom_id", "id", - "label", - "description", "required", "default_values", ) @@ -213,8 +201,6 @@ def __init__( disabled: bool = ..., row: int | None = ..., id: int | None = ..., - label: str | None = ..., - description: str | None = ..., required: bool | None = ..., ) -> None: ... @@ -231,8 +217,6 @@ def __init__( disabled: bool = ..., row: int | None = ..., id: int | None = ..., - label: str | None = ..., - description: str | None = ..., required: bool | None = ..., default_values: Sequence[SelectDefaultValue | ST] | None = ..., ) -> None: ... @@ -253,8 +237,6 @@ def __init__( disabled: bool = ..., row: int | None = ..., id: int | None = ..., - label: str | None = ..., - description: str | None = ..., required: bool | None = ..., default_values: Sequence[SelectDefaultValue | ST] | None = ..., ) -> None: ... @@ -272,17 +254,11 @@ def __init__( disabled: bool = False, row: int | None = None, id: int | None = None, - label: str | None = None, - description: str | None = None, required: bool | None = None, default_values: Sequence[SelectDefaultValue | ST] | 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 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" @@ -303,9 +279,6 @@ 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( @@ -782,9 +755,6 @@ 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)) - if TYPE_CHECKING: StringSelect = Select[V, str] From a1068fbf7124ddbb4e06cdbdf5d61ecb7cb574dd Mon Sep 17 00:00:00 2001 From: UK <41271523+NeloBlivion@users.noreply.github.com> Date: Mon, 22 Sep 2025 16:45:55 -0400 Subject: [PATCH 28/99] update refresh logic --- discord/ui/input_text.py | 2 +- discord/ui/label.py | 3 +++ discord/ui/modal.py | 43 ++++++++++++++++++++++------------------ discord/ui/select.py | 2 +- discord/ui/view.py | 14 ++++++------- 5 files changed, 36 insertions(+), 28 deletions(-) diff --git a/discord/ui/input_text.py b/discord/ui/input_text.py index 3b350bd9c3..f9ce85053d 100644 --- a/discord/ui/input_text.py +++ b/discord/ui/input_text.py @@ -238,7 +238,7 @@ def to_component_dict(self) -> InputTextComponentPayload: def refresh_state(self, data) -> None: self._input_value = data["value"] - def refresh_from_modal(self, interaction: Interaction, data: dict) -> None: + def refresh_from_modal(self, interaction: Interaction, data: InputTextComponentPayload) -> None: return self.refresh_state(data) diff --git a/discord/ui/label.py b/discord/ui/label.py index c93689288c..d69c8bd7df 100644 --- a/discord/ui/label.py +++ b/discord/ui/label.py @@ -270,6 +270,9 @@ def to_component_dict(self) -> LabelPayload: self._set_component_from_item(self.item) return self._underlying.to_dict() + def refresh_from_modal(self, interaction: Interaction, data: LabelPayload) -> 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 diff --git a/discord/ui/modal.py b/discord/ui/modal.py index 2af763e37c..d273886ed4 100644 --- a/discord/ui/modal.py +++ b/discord/ui/modal.py @@ -30,6 +30,7 @@ from ..interactions import Interaction from ..state import ConnectionState + from ..types.components import Component as ComponentPayload M = TypeVar("M", bound="Modal", covariant=True) @@ -343,6 +344,18 @@ def remove_item(self, item: InputText) -> Self: 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. @@ -406,6 +419,10 @@ def add_item(self, item: ModalItem) -> Self: 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",) @@ -465,27 +482,15 @@ def remove_modal(self, modal: BaseModal, user_id): async def dispatch(self, user_id: int, custom_id: str, interaction: Interaction): key = (user_id, custom_id) - value = self._modals.get(key) + modal = self._modals.get(key) if value is None: return - interaction.modal = value + interaction.modal = modal try: - components = [ - component - for parent_component in interaction.data["components"] - for component in ( - parent_component.get("components") - or ( - [parent_component.get("component")] - if parent_component.get("component") - else [parent_component] - ) - ) - ] - for component, child in zip(components, value.children): - child.refresh_from_modal(interaction, component) - await value.callback(interaction) - self.remove_modal(value, user_id) + components = interaction.data["components"] + modal.refresh(interaction, components) + await modal.callback(interaction) + self.remove_modal(modal, user_id) except Exception as e: - return await value.on_error(e, interaction) + return await modal.on_error(e, interaction) diff --git a/discord/ui/select.py b/discord/ui/select.py index e6a6617cdf..65c4511279 100644 --- a/discord/ui/select.py +++ b/discord/ui/select.py @@ -724,7 +724,7 @@ def refresh_state(self, interaction: Interaction | dict) -> None: self._selected_values = data.get("values", []) self._interaction = interaction - def refresh_from_modal(self, interaction: Interaction | dict, data: dict) -> None: + def refresh_from_modal(self, interaction: Interaction | dict, data: SelectMenuPayload) -> None: self._selected_values = data.get("values", []) self._interaction = interaction diff --git a/discord/ui/view.py b/discord/ui/view.py index 352bfe1502..f1a2e8db6f 100644 --- a/discord/ui/view.py +++ b/discord/ui/view.py @@ -737,6 +737,12 @@ def refresh(self, components: list[Component]): self.children = children def is_components_v2(self) -> bool: + """Whether the view contains V2 components. + + A view containing V2 components cannot be sent alongside message content or embeds. + + This is always ``False`` for :class:`View`. + """ return False @@ -883,13 +889,7 @@ def refresh(self, components: list[Component]): i += 1 def is_components_v2(self) -> bool: - """Whether the view contains V2 components or requires the V2 flag. - - A view containing V2 components cannot be sent alongside message content or embeds. - """ - return len(self.children) > 5 or any( - i.is_components_v2() for i in self.children - ) + return len(self.children) > 5 or super().is_components_v2() class ViewStore: From e199c8c9ff8fba3f8bf4fc3609c979e9c5a59de1 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Mon, 22 Sep 2025 20:46:22 +0000 Subject: [PATCH 29/99] style(pre-commit): auto fixes from pre-commit.com hooks --- discord/ui/input_text.py | 4 +++- discord/ui/select.py | 4 +++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/discord/ui/input_text.py b/discord/ui/input_text.py index f9ce85053d..ecd7c2688e 100644 --- a/discord/ui/input_text.py +++ b/discord/ui/input_text.py @@ -238,7 +238,9 @@ def to_component_dict(self) -> InputTextComponentPayload: def refresh_state(self, data) -> None: self._input_value = data["value"] - def refresh_from_modal(self, interaction: Interaction, data: InputTextComponentPayload) -> None: + def refresh_from_modal( + self, interaction: Interaction, data: InputTextComponentPayload + ) -> None: return self.refresh_state(data) diff --git a/discord/ui/select.py b/discord/ui/select.py index 65c4511279..5d641baf37 100644 --- a/discord/ui/select.py +++ b/discord/ui/select.py @@ -724,7 +724,9 @@ def refresh_state(self, interaction: Interaction | dict) -> None: self._selected_values = data.get("values", []) self._interaction = interaction - def refresh_from_modal(self, interaction: Interaction | dict, data: SelectMenuPayload) -> None: + def refresh_from_modal( + self, interaction: Interaction | dict, data: SelectMenuPayload + ) -> None: self._selected_values = data.get("values", []) self._interaction = interaction From 0dc7522086fe20cb0a7c2ee5521c634826efbd93 Mon Sep 17 00:00:00 2001 From: UK <41271523+NeloBlivion@users.noreply.github.com> Date: Mon, 22 Sep 2025 16:49:21 -0400 Subject: [PATCH 30/99] typext --- discord/ui/view.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/discord/ui/view.py b/discord/ui/view.py index f1a2e8db6f..65202b75bd 100644 --- a/discord/ui/view.py +++ b/discord/ui/view.py @@ -31,7 +31,9 @@ import time from functools import partial from itertools import groupby -from typing import TYPE_CHECKING, Any, ClassVar, Iterator, Self, Sequence, TypeVar +from typing import TYPE_CHECKING, Any, ClassVar, Iterator, Sequence, TypeVar + +from typing_extensions import Self from ..components import ActionRow as ActionRowComponent from ..components import Button as ButtonComponent From bfd4aed5b03aebf07424c576bc6e1169157a48dc Mon Sep 17 00:00:00 2001 From: UK <41271523+NeloBlivion@users.noreply.github.com> Date: Mon, 22 Sep 2025 16:51:39 -0400 Subject: [PATCH 31/99] reduce further --- discord/ui/core.py | 20 -------------------- 1 file changed, 20 deletions(-) diff --git a/discord/ui/core.py b/discord/ui/core.py index a9f50b2aed..6210be6ade 100644 --- a/discord/ui/core.py +++ b/discord/ui/core.py @@ -29,23 +29,8 @@ from itertools import groupby from typing import TYPE_CHECKING, Any, Callable -from ..components import ActionRow as ActionRowComponent -from ..components import Button as ButtonComponent -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 -from ..components import Separator as SeparatorComponent -from ..components import TextDisplay as TextDisplayComponent -from ..components import Thumbnail as ThumbnailComponent -from ..components import _component_factory from ..utils import find, get -from .action_row import ActionRow from .item import Item, ItemCallbackType -from .view import View __all__ = "ItemInterface" @@ -53,11 +38,6 @@ if TYPE_CHECKING: from typing_extensions import Self - from ..interactions import Interaction, InteractionMessage - from ..message import Message - from ..state import ConnectionState - from ..types.components import Component as ComponentPayload - class ItemInterface: """The base structure for classes that contain :class:`~discord.ui.Item`. From 1223deac2423ca8224aa68deb63389d06f3ba7cb Mon Sep 17 00:00:00 2001 From: UK <41271523+NeloBlivion@users.noreply.github.com> Date: Mon, 22 Sep 2025 16:54:55 -0400 Subject: [PATCH 32/99] fix __all__ --- discord/ui/core.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/discord/ui/core.py b/discord/ui/core.py index 6210be6ade..93c872da6d 100644 --- a/discord/ui/core.py +++ b/discord/ui/core.py @@ -32,7 +32,7 @@ from ..utils import find, get from .item import Item, ItemCallbackType -__all__ = "ItemInterface" +__all__ = ("ItemInterface", ) if TYPE_CHECKING: From 3a82843a5e2f7191ba902dd86a6e466b33048963 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Mon, 22 Sep 2025 20:55:22 +0000 Subject: [PATCH 33/99] style(pre-commit): auto fixes from pre-commit.com hooks --- discord/ui/core.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/discord/ui/core.py b/discord/ui/core.py index 93c872da6d..a5707663b3 100644 --- a/discord/ui/core.py +++ b/discord/ui/core.py @@ -32,7 +32,7 @@ from ..utils import find, get from .item import Item, ItemCallbackType -__all__ = ("ItemInterface", ) +__all__ = ("ItemInterface",) if TYPE_CHECKING: From 25fbd2a931a7862bd56f89c3132b0a29a0ca4b37 Mon Sep 17 00:00:00 2001 From: UK <41271523+NeloBlivion@users.noreply.github.com> Date: Mon, 22 Sep 2025 16:57:56 -0400 Subject: [PATCH 34/99] typecheck --- discord/ui/modal.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/discord/ui/modal.py b/discord/ui/modal.py index d273886ed4..8d40472a60 100644 --- a/discord/ui/modal.py +++ b/discord/ui/modal.py @@ -178,7 +178,7 @@ def add_item(self, item: ModalItem) -> Self: if len(self._children) > 5: raise ValueError("You can only have up to 5 items in a modal.") - if not isinstance(item, (Item,)): + if not isinstance(item, (Item, InputText, )): raise TypeError(f"expected Item, not {item.__class__!r}") self._children.append(item) From 3ee00e0bdbc2ccbc0ddb20cf17ef3927a927d254 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Mon, 22 Sep 2025 20:58:24 +0000 Subject: [PATCH 35/99] style(pre-commit): auto fixes from pre-commit.com hooks --- discord/ui/modal.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/discord/ui/modal.py b/discord/ui/modal.py index 8d40472a60..13de9b0ada 100644 --- a/discord/ui/modal.py +++ b/discord/ui/modal.py @@ -178,7 +178,13 @@ def add_item(self, item: ModalItem) -> Self: if len(self._children) > 5: raise ValueError("You can only have up to 5 items in a modal.") - if not isinstance(item, (Item, InputText, )): + if not isinstance( + item, + ( + Item, + InputText, + ), + ): raise TypeError(f"expected Item, not {item.__class__!r}") self._children.append(item) From 61a14330796ba0fb60b6dd1a70685564dd73ca92 Mon Sep 17 00:00:00 2001 From: UK <41271523+NeloBlivion@users.noreply.github.com> Date: Mon, 22 Sep 2025 17:02:09 -0400 Subject: [PATCH 36/99] __ --- discord/ui/core.py | 16 ++++++++-------- discord/ui/modal.py | 18 +++++++++--------- discord/ui/view.py | 42 +++++++++++++++++++++--------------------- 3 files changed, 38 insertions(+), 38 deletions(-) diff --git a/discord/ui/core.py b/discord/ui/core.py index a5707663b3..b2c4fdbe07 100644 --- a/discord/ui/core.py +++ b/discord/ui/core.py @@ -72,30 +72,30 @@ def __init__( 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._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() def __repr__(self) -> str: return f"<{self.__class__.__name__} timeout={self.timeout} children={len(self.children)}>" - async def __timeout_task_impl(self) -> None: + 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: + 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: + 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) + await asyncio.sleep(self._timeout_expiry - now) @property def _expires_at(self) -> float | None: diff --git a/discord/ui/modal.py b/discord/ui/modal.py index 13de9b0ada..a637475b4d 100644 --- a/discord/ui/modal.py +++ b/discord/ui/modal.py @@ -92,14 +92,14 @@ def __repr__(self) -> str: return f"<{self.__class__.__name__} {attrs}>" def _start_listening_from_store(self, store: ModalStore) -> None: - self.__cancel_callback = partial(store.remove_modal) + 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() + 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()) + 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(): @@ -208,10 +208,10 @@ 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 + 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.""" diff --git a/discord/ui/view.py b/discord/ui/view.py index 65202b75bd..b7b9539f18 100644 --- a/discord/ui/view.py +++ b/discord/ui/view.py @@ -360,7 +360,7 @@ def is_components_v2(self) -> bool: async def _scheduled_task(self, item: Item[V], interaction: Interaction): try: if self.timeout: - self.__timeout_expiry = time.monotonic() + self.timeout + self._timeout_expiry = time.monotonic() + self.timeout allow = await self.interaction_check(interaction) if not allow: @@ -371,26 +371,26 @@ async def _scheduled_task(self, item: Item[V], interaction: Interaction): return await self.on_error(e, item, interaction) def _start_listening_from_store(self, store: ViewStore) -> None: - self.__cancel_callback = partial(store.remove_view) + self._cancel_callback = partial(store.remove_view) if self.timeout: loop = asyncio.get_running_loop() - if self.__timeout_task is not None: - self.__timeout_task.cancel() + 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()) + 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(): + if self._stopped.done(): return - self.__stopped.set_result(True) + self._stopped.set_result(True) asyncio.create_task( self.on_timeout(), name=f"discord-ui-view-timeout-{self.id}" ) def _dispatch_item(self, item: Item[V], interaction: Interaction): - if self.__stopped.done(): + if self._stopped.done(): return if interaction.message: @@ -403,14 +403,14 @@ def _dispatch_item(self, item: Item[V], interaction: Interaction): def is_finished(self) -> bool: """Whether the view has finished interacting.""" - return self.__stopped.done() + return self._stopped.done() def is_dispatchable(self) -> bool: return any(item.is_dispatchable() for item in self.children) def is_dispatching(self) -> bool: """Whether the view has been added for dispatching purposes.""" - return self.__cancel_callback is not None + return self._cancel_callback is not None def is_persistent(self) -> bool: """Whether the view is set up as persistent. @@ -427,17 +427,17 @@ def stop(self) -> None: This operation cannot be undone. """ - if not self.__stopped.done(): - self.__stopped.set_result(False) + if not self._stopped.done(): + self._stopped.set_result(False) - self.__timeout_expiry = None - if self.__timeout_task is not None: - self.__timeout_task.cancel() - self.__timeout_task = None + self._timeout_expiry = None + if self._timeout_task is not None: + self._timeout_task.cancel() + self._timeout_task = None - if self.__cancel_callback: - self.__cancel_callback(self) - self.__cancel_callback = None + if self._cancel_callback: + self._cancel_callback(self) + self._cancel_callback = None async def wait(self) -> bool: """Waits until the view has finished interacting. @@ -451,7 +451,7 @@ async def wait(self) -> bool: If ``True``, then the view timed out. If ``False`` then the view finished normally. """ - return await self.__stopped + return await self._stopped def disable_all_items(self, *, exclusions: list[Item[V]] | None = None) -> Self: """ From edb4951e2c95db6bb20c98fd30cb294046f0cb1a Mon Sep 17 00:00:00 2001 From: UK <41271523+NeloBlivion@users.noreply.github.com> Date: Mon, 22 Sep 2025 17:03:06 -0400 Subject: [PATCH 37/99] dispatch --- discord/ui/modal.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/discord/ui/modal.py b/discord/ui/modal.py index a637475b4d..8d9cd2bd3b 100644 --- a/discord/ui/modal.py +++ b/discord/ui/modal.py @@ -489,7 +489,7 @@ def remove_modal(self, modal: BaseModal, user_id): async def dispatch(self, user_id: int, custom_id: str, interaction: Interaction): key = (user_id, custom_id) modal = self._modals.get(key) - if value is None: + if modal is None: return interaction.modal = modal From d9c465b79355daf6208cb10d9092d07baadc84e7 Mon Sep 17 00:00:00 2001 From: UK <41271523+NeloBlivion@users.noreply.github.com> Date: Mon, 22 Sep 2025 17:06:40 -0400 Subject: [PATCH 38/99] ComponentType --- discord/enums.py | 1 + 1 file changed, 1 insertion(+) diff --git a/discord/enums.py b/discord/enums.py index cc2429a84c..457fbc07dc 100644 --- a/discord/enums.py +++ b/discord/enums.py @@ -734,6 +734,7 @@ class ComponentType(Enum): separator = 14 content_inventory_entry = 16 container = 17 + label = 18 def __int__(self): return self.value From 1ae1c7747596f98c68820dfdd23e04fa34c21522 Mon Sep 17 00:00:00 2001 From: UK <41271523+NeloBlivion@users.noreply.github.com> Date: Mon, 22 Sep 2025 17:08:16 -0400 Subject: [PATCH 39/99] types ? --- discord/ui/core.py | 1 + discord/ui/label.py | 1 + 2 files changed, 2 insertions(+) diff --git a/discord/ui/core.py b/discord/ui/core.py index b2c4fdbe07..e8fd84c55f 100644 --- a/discord/ui/core.py +++ b/discord/ui/core.py @@ -37,6 +37,7 @@ if TYPE_CHECKING: from typing_extensions import Self + from .view import View class ItemInterface: diff --git a/discord/ui/label.py b/discord/ui/label.py index d69c8bd7df..e038efc6e5 100644 --- a/discord/ui/label.py +++ b/discord/ui/label.py @@ -17,6 +17,7 @@ from typing_extensions import Self from ..emoji import AppEmoji, GuildEmoji + from ..interaction import Interaction from ..partial_emoji import PartialEmoji, _EmojiTag from ..types.components import Label as LabelPayload from .view import View From bd60e9bb25d49df00abc1e8f6a08715dd73c9997 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Mon, 22 Sep 2025 21:08:43 +0000 Subject: [PATCH 40/99] style(pre-commit): auto fixes from pre-commit.com hooks --- discord/ui/core.py | 1 + 1 file changed, 1 insertion(+) diff --git a/discord/ui/core.py b/discord/ui/core.py index e8fd84c55f..b54cc2273c 100644 --- a/discord/ui/core.py +++ b/discord/ui/core.py @@ -37,6 +37,7 @@ if TYPE_CHECKING: from typing_extensions import Self + from .view import View From 090af2e16333a5645d8b9399183d3958eb9f6274 Mon Sep 17 00:00:00 2001 From: UK <41271523+NeloBlivion@users.noreply.github.com> Date: Mon, 22 Sep 2025 17:10:07 -0400 Subject: [PATCH 41/99] typecheck again --- discord/ui/label.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/discord/ui/label.py b/discord/ui/label.py index e038efc6e5..486694a5b5 100644 --- a/discord/ui/label.py +++ b/discord/ui/label.py @@ -88,7 +88,7 @@ def set_item(self, item: Item) -> Self: Parameters ---------- - item: :class:`Item` + item: Union[:class:`Item`, :class:`InputText`] The item to set. Currently only supports :class:`~discord.ui.Select` and :class:`~discord.ui.InputText`. @@ -98,7 +98,7 @@ def set_item(self, item: Item) -> Self: An :class:`Item` was not passed. """ - if not isinstance(item, Item): + if not isinstance(item, (Item, InputText)): raise TypeError(f"expected Item not {item.__class__!r}") if isinstance(item, InputText) and item.label: raise ValueError(f"InputText.label cannot be set inside Label") From 00faca4ae986d9aa4d57cec789dd43ab516bee84 Mon Sep 17 00:00:00 2001 From: UK <41271523+NeloBlivion@users.noreply.github.com> Date: Mon, 22 Sep 2025 17:15:24 -0400 Subject: [PATCH 42/99] attrs --- discord/components.py | 2 +- discord/ui/file.py | 4 ---- discord/ui/input_text.py | 4 ---- discord/ui/label.py | 24 ++++++++++++++++++++---- discord/ui/media_gallery.py | 4 ---- discord/ui/section.py | 4 ---- discord/ui/separator.py | 4 ---- discord/ui/text_display.py | 4 ---- discord/ui/thumbnail.py | 4 ---- 9 files changed, 21 insertions(+), 33 deletions(-) diff --git a/discord/components.py b/discord/components.py index 736e57bfb6..a44768a7eb 100644 --- a/discord/components.py +++ b/discord/components.py @@ -176,7 +176,7 @@ def __init__(self, data: ComponentPayload): @property def width(self): - """Return the sum of the item's widths.""" + """Return the sum of the items' widths.""" t = 0 for item in self.children: t += 1 if item.type is ComponentType.button else 5 diff --git a/discord/ui/file.py b/discord/ui/file.py index dc06b83648..1c7a9c932d 100644 --- a/discord/ui/file.py +++ b/discord/ui/file.py @@ -58,10 +58,6 @@ def __init__(self, url: str, *, spoiler: bool = False, id: int | None = None): def type(self) -> ComponentType: return self._underlying.type - @property - def width(self) -> int: - return 5 - @property def url(self) -> str: """The URL of this file's media. This must be an ``attachment://`` URL that references a :class:`~discord.File`.""" diff --git a/discord/ui/input_text.py b/discord/ui/input_text.py index ecd7c2688e..9ccf73597d 100644 --- a/discord/ui/input_text.py +++ b/discord/ui/input_text.py @@ -228,10 +228,6 @@ def value(self, value: str | None): 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 self._underlying.to_dict() diff --git a/discord/ui/label.py b/discord/ui/label.py index 486694a5b5..bd58628110 100644 --- a/discord/ui/label.py +++ b/discord/ui/label.py @@ -42,11 +42,9 @@ class Label(Item[V]): item: :class:`Item` The initial item in this label. label: :class:`str` - The label text. - Must be 45 characters or fewer. + The label text. Must be 45 characters or fewer. description: Optional[:class:`str`] - The description for this label. - Must be 100 characters or fewer. + The description for this label. Must be 100 characters or fewer. id: Optional[:class:`int`] The label's ID. """ @@ -254,6 +252,24 @@ def view(self, value): def type(self) -> ComponentType: return self._underlying.type + @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() diff --git a/discord/ui/media_gallery.py b/discord/ui/media_gallery.py index b50daef71c..246e34c282 100644 --- a/discord/ui/media_gallery.py +++ b/discord/ui/media_gallery.py @@ -113,10 +113,6 @@ def view(self, value): def type(self) -> ComponentType: return self._underlying.type - @property - def width(self) -> int: - return 5 - def to_component_dict(self) -> MediaGalleryComponentPayload: return self._underlying.to_dict() diff --git a/discord/ui/section.py b/discord/ui/section.py index 922f4819ad..a991529fa3 100644 --- a/discord/ui/section.py +++ b/discord/ui/section.py @@ -251,10 +251,6 @@ def copy_text(self) -> str: def type(self) -> ComponentType: return self._underlying.type - @property - def width(self) -> int: - return 5 - def is_dispatchable(self) -> bool: return self.accessory and self.accessory.is_dispatchable() diff --git a/discord/ui/separator.py b/discord/ui/separator.py index 6b81674401..c9e213b438 100644 --- a/discord/ui/separator.py +++ b/discord/ui/separator.py @@ -77,10 +77,6 @@ def spacing(self) -> SeparatorSpacingSize: def spacing(self, value: SeparatorSpacingSize) -> None: self._underlying.spacing = value - @property - def width(self) -> int: - return 5 - def to_component_dict(self) -> SeparatorComponentPayload: return self._underlying.to_dict() diff --git a/discord/ui/text_display.py b/discord/ui/text_display.py index 6624500a3f..18acfee16e 100644 --- a/discord/ui/text_display.py +++ b/discord/ui/text_display.py @@ -62,10 +62,6 @@ def content(self) -> str: def content(self, value: str) -> None: self._underlying.content = value - @property - def width(self) -> int: - return 5 - def to_component_dict(self) -> TextDisplayComponentPayload: return self._underlying.to_dict() diff --git a/discord/ui/thumbnail.py b/discord/ui/thumbnail.py index f14e3022eb..28f92808e7 100644 --- a/discord/ui/thumbnail.py +++ b/discord/ui/thumbnail.py @@ -66,10 +66,6 @@ def __init__( def type(self) -> ComponentType: return self._underlying.type - @property - def width(self) -> int: - return 5 - @property def url(self) -> str: """The URL of this thumbnail's media. This can either be an arbitrary URL or an ``attachment://`` URL.""" From 5c7a02bd6d58438f135dbb7c2ec5a9a416d51a94 Mon Sep 17 00:00:00 2001 From: UK <41271523+NeloBlivion@users.noreply.github.com> Date: Mon, 22 Sep 2025 17:20:52 -0400 Subject: [PATCH 43/99] fixes --- discord/ui/label.py | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/discord/ui/label.py b/discord/ui/label.py index bd58628110..b3371c8999 100644 --- a/discord/ui/label.py +++ b/discord/ui/label.py @@ -58,7 +58,7 @@ class Label(Item[V]): def __init__( self, - item: Item, + item: Item = None, *, label: str, description: str | None = None, @@ -76,7 +76,8 @@ def __init__( description=description, ) - self.set_item(item) + if item: + self.set_item(item) def _set_component_from_item(self, item: Item): self._underlying.component = item._underlying @@ -193,7 +194,7 @@ def set_select( max_values: int = 1, options: list[SelectOption] | None = None, channel_types: list[ChannelType] | None = None, - disabled: bool = False, + required: bool = True, id: int | None = None, ) -> Self: """Set this label's item to a select menu. @@ -222,8 +223,8 @@ def set_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``. + required: :class:`bool` + Whether the select is required or not. Defaults to ``True``. id: Optional[:class:`int`] The select menu's ID. """ @@ -236,7 +237,7 @@ def set_select( max_values=max_values, options=options or [], channel_types=channel_types or [], - disabled=disabled, + required=required, id=id, ) From 1975f6d28eead04cb792d18a27b049d6c0db1c5b Mon Sep 17 00:00:00 2001 From: UK <41271523+NeloBlivion@users.noreply.github.com> Date: Mon, 22 Sep 2025 17:35:21 -0400 Subject: [PATCH 44/99] adjust --- discord/abc.py | 2 +- discord/channel.py | 2 +- discord/ui/view.py | 3 +-- 3 files changed, 3 insertions(+), 4 deletions(-) diff --git a/discord/abc.py b/discord/abc.py index da3d6ddd18..54b75bea85 100644 --- a/discord/abc.py +++ b/discord/abc.py @@ -1611,7 +1611,7 @@ async def send( if view: if not hasattr(view, "__discord_ui_view__"): raise InvalidArgument( - f"view parameter must be View not {view.__class__!r}" + f"view parameter must be BaseView not {view.__class__!r}" ) components = view.to_components() diff --git a/discord/channel.py b/discord/channel.py index e5e55f360c..1771b9c69a 100644 --- a/discord/channel.py +++ b/discord/channel.py @@ -1328,7 +1328,7 @@ async def create_thread( if view: if not hasattr(view, "__discord_ui_view__"): raise InvalidArgument( - f"view parameter must be View not {view.__class__!r}" + f"view parameter must be BaseView not {view.__class__!r}" ) components = view.to_components() diff --git a/discord/ui/view.py b/discord/ui/view.py index b7b9539f18..de271f25cb 100644 --- a/discord/ui/view.py +++ b/discord/ui/view.py @@ -203,6 +203,7 @@ def fits_legacy(self, item) -> bool: class BaseView(ItemInterface): """The base class for UI views used in messages.""" + __discord_ui_view__: ClassVar[bool] = True MAX_ITEMS: int def __init__( @@ -546,8 +547,6 @@ class View(BaseView): The parent interaction which this view was sent from. If ``None`` then the view was not sent using :meth:`InteractionResponse.send_message`. """ - - __discord_ui_view__: ClassVar[bool] = True __view_children_items__: ClassVar[list[ItemCallbackType]] = [] MAX_ITEMS: int = 25 From 2816b1b4d06099d82b76f917943fac1b7f7c9acd Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Mon, 22 Sep 2025 21:35:48 +0000 Subject: [PATCH 45/99] style(pre-commit): auto fixes from pre-commit.com hooks --- discord/ui/view.py | 1 + 1 file changed, 1 insertion(+) diff --git a/discord/ui/view.py b/discord/ui/view.py index de271f25cb..5c46190029 100644 --- a/discord/ui/view.py +++ b/discord/ui/view.py @@ -547,6 +547,7 @@ class View(BaseView): The parent interaction which this view was sent from. If ``None`` then the view was not sent using :meth:`InteractionResponse.send_message`. """ + __view_children_items__: ClassVar[list[ItemCallbackType]] = [] MAX_ITEMS: int = 25 From e615190273cd9167a1492d5aba0eec682ff76a5c Mon Sep 17 00:00:00 2001 From: UK <41271523+NeloBlivion@users.noreply.github.com> Date: Mon, 22 Sep 2025 17:39:05 -0400 Subject: [PATCH 46/99] actionrow components --- discord/components.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/discord/components.py b/discord/components.py index a44768a7eb..6fdce8a6f1 100644 --- a/discord/components.py +++ b/discord/components.py @@ -192,6 +192,10 @@ def to_dict(self) -> ActionRowPayload: def walk_components(self) -> Iterator[Component]: yield from self.children + @property + def components(self) -> list[Component]: + return self.children + def get_component(self, id: str | int) -> Component | None: """Get a component from this action row. Roughly equivalent to `utils.get(row.children, ...)`. If an ``int`` is provided, the component will be retrieved by ``id``, otherwise by ``custom_id``. From 93fb09a6ecb7246409f1dae8e8de414d50e4a282 Mon Sep 17 00:00:00 2001 From: UK <41271523+NeloBlivion@users.noreply.github.com> Date: Mon, 22 Sep 2025 17:46:35 -0400 Subject: [PATCH 47/99] arc --- discord/ui/action_row.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/discord/ui/action_row.py b/discord/ui/action_row.py index c6bb584bee..bd3ea21d2a 100644 --- a/discord/ui/action_row.py +++ b/discord/ui/action_row.py @@ -73,7 +73,7 @@ def __init__( self._underlying = ActionRowComponent._raw_construct( type=ComponentType.action_row, id=id, - components=[], + children=[], ) for func in self.__row_children_items__: From 0517d2e046021cb8e46295355a1503208e739a58 Mon Sep 17 00:00:00 2001 From: UK <41271523+NeloBlivion@users.noreply.github.com> Date: Mon, 22 Sep 2025 18:22:09 -0400 Subject: [PATCH 48/99] button priority --- discord/ui/action_row.py | 30 ++++++++++++++++++++++++++++-- discord/ui/button.py | 22 +++++++++++++++++++++- discord/ui/container.py | 6 ++++++ discord/ui/view.py | 6 ++++++ 4 files changed, 61 insertions(+), 3 deletions(-) diff --git a/discord/ui/action_row.py b/discord/ui/action_row.py index bd3ea21d2a..fa35448449 100644 --- a/discord/ui/action_row.py +++ b/discord/ui/action_row.py @@ -91,6 +91,14 @@ def _add_component_from_item(self, item: Item): def _set_components(self, items: list[Item]): self._underlying.components.clear() + if not any(isinstance(b, Select) for b in self.children): + a, b = [], [] + for i in items: + if i.priority is None: + b.append(i) + else: + a.append(i) + items = sorted(a, key=lambda b: b.priority) + b for item in items: self._add_component_from_item(item) @@ -108,8 +116,12 @@ def add_item(self, item: Item) -> Self: An :class:`Item` was not passed. """ - if not isinstance(item, Item): - raise TypeError(f"expected Item not {item.__class__!r}") + 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._view = self.view item.parent = self @@ -166,6 +178,7 @@ def add_button( emoji: str | GuildEmoji | AppEmoji | PartialEmoji | None = None, sku_id: int | None = None, id: int | None = None, + priority: int | None = None ) -> Self: """Adds a :class:`Button` to the action row. @@ -191,6 +204,10 @@ def add_button( The ID of the SKU this button refers to. id: Optional[:class:`int`] The button's ID. + priority: Optional[:class:`int`] + An integer greater than 0. If specified, decides the position + of the button in this row instead of going by order of addition. The lower this number, the earlier its position. + This ActionRow's children will be reordered when the View containing it is sent. A priority of ``None`` will be ordered after any specified priority. """ button = Button( @@ -202,6 +219,7 @@ def add_button( emoji=emoji, sku_id=sku_id, id=id, + priority=priority ) return self.add_item(button) @@ -320,6 +338,14 @@ def enable_all_items(self, *, exclusions: list[Item] | None = None) -> Self: 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[Item]: yield from self.children diff --git a/discord/ui/button.py b/discord/ui/button.py index f08de8d193..f93de24d5e 100644 --- a/discord/ui/button.py +++ b/discord/ui/button.py @@ -78,10 +78,14 @@ class Button(Item[V]): .. warning:: - This parameter does not work with V2 components or with more than 25 items in your view. + This parameter does not work in :class:`ActionRow`. id: Optional[:class:`int`] The button's ID. + priority: Optional[:class:`int`] + Only works in :class:`ActionRow`. Any integer greater than 0. If specified, decides the position + of the button in this row instead of going by order of addition. The lower this number, the earlier its position. + The ActionRow's children will be reordered when the View containing this button is sent. """ __item_repr_attributes__: tuple[str, ...] = ( @@ -94,6 +98,7 @@ class Button(Item[V]): "row", "custom_id", "id", + "priority", ) def __init__( @@ -108,6 +113,7 @@ def __init__( sku_id: int | None = None, row: int | None = None, id: int | None = None, + priority: int | None = None, ): super().__init__() if label and len(str(label)) > 80: @@ -120,11 +126,14 @@ def __init__( 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 priority and (priority < 0 or not isinstance(priority, int)): + raise ValueError("priority must be an integer greater than 0") 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.priority: int | None = priority self._provided_custom_id = custom_id is not None if url is None and custom_id is None and sku_id is None: @@ -294,6 +303,7 @@ def button( emoji: str | GuildEmoji | AppEmoji | PartialEmoji | None = None, row: int | None = None, id: int | None = None, + priority: int | None = None, ) -> Callable[[ItemCallbackType[Button[V]]], Button[V]]: """A decorator that attaches a button to a component. @@ -328,6 +338,15 @@ def button( like to control the relative positioning of the row then passing an index is advised. For example, row=1 will show up before row=2. Defaults to ``None``, which is automatic ordering. The row number must be between 0 and 4 (i.e. zero indexed). + + .. warning:: + + This parameter does not work in :class:`ActionRow`. + + priority: Optional[:class:`int`] + Only works in :class:`ActionRow`. Any integer greater than 0. If specified, decides the position + of the button in this row instead of going by order of addition. The lower this number, the earlier its position. + The ActionRow's children will be reordered when the View containing it is sent. A priority of ``None`` will be ordered after any specified priority. """ def decorator(func: ItemCallbackType) -> ItemCallbackType: @@ -344,6 +363,7 @@ def decorator(func: ItemCallbackType) -> ItemCallbackType: "emoji": emoji, "row": row, "id": id, + "priority": priority } return func diff --git a/discord/ui/container.py b/discord/ui/container.py index be047c88b6..abed8feb45 100644 --- a/discord/ui/container.py +++ b/discord/ui/container.py @@ -65,6 +65,12 @@ class Container(Item[V]): "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: Item, diff --git a/discord/ui/view.py b/discord/ui/view.py index 5c46190029..65123869dc 100644 --- a/discord/ui/view.py +++ b/discord/ui/view.py @@ -782,6 +782,12 @@ class DesignerView(BaseView): MAX_ITEMS: int = 40 + 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 DesignerView. Use ActionRow instead.") + def __init__( self, *items: Item[V], From e32fdad702dda289ebc3a8f1cd7d8d256767e669 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Mon, 22 Sep 2025 22:22:37 +0000 Subject: [PATCH 49/99] style(pre-commit): auto fixes from pre-commit.com hooks --- discord/ui/action_row.py | 4 ++-- discord/ui/button.py | 2 +- discord/ui/container.py | 4 +++- discord/ui/view.py | 4 +++- 4 files changed, 9 insertions(+), 5 deletions(-) diff --git a/discord/ui/action_row.py b/discord/ui/action_row.py index fa35448449..aa38df9557 100644 --- a/discord/ui/action_row.py +++ b/discord/ui/action_row.py @@ -178,7 +178,7 @@ def add_button( emoji: str | GuildEmoji | AppEmoji | PartialEmoji | None = None, sku_id: int | None = None, id: int | None = None, - priority: int | None = None + priority: int | None = None, ) -> Self: """Adds a :class:`Button` to the action row. @@ -219,7 +219,7 @@ def add_button( emoji=emoji, sku_id=sku_id, id=id, - priority=priority + priority=priority, ) return self.add_item(button) diff --git a/discord/ui/button.py b/discord/ui/button.py index f93de24d5e..aa3faae5e4 100644 --- a/discord/ui/button.py +++ b/discord/ui/button.py @@ -363,7 +363,7 @@ def decorator(func: ItemCallbackType) -> ItemCallbackType: "emoji": emoji, "row": row, "id": id, - "priority": priority + "priority": priority, } return func diff --git a/discord/ui/container.py b/discord/ui/container.py index abed8feb45..e289c4cc5b 100644 --- a/discord/ui/container.py +++ b/discord/ui/container.py @@ -69,7 +69,9 @@ 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.") + raise ValueError( + "The @button and @select decorators are incompatible with Container. Use ActionRow instead." + ) def __init__( self, diff --git a/discord/ui/view.py b/discord/ui/view.py index 65123869dc..45470ced69 100644 --- a/discord/ui/view.py +++ b/discord/ui/view.py @@ -786,7 +786,9 @@ 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 DesignerView. Use ActionRow instead.") + raise ValueError( + "The @button and @select decorators are incompatible with DesignerView. Use ActionRow instead." + ) def __init__( self, From bdb6d217b950eec56a1e6dc89897b4d57c3f3e20 Mon Sep 17 00:00:00 2001 From: UK <41271523+NeloBlivion@users.noreply.github.com> Date: Mon, 22 Sep 2025 18:32:35 -0400 Subject: [PATCH 50/99] maybe? --- discord/ui/action_row.py | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/discord/ui/action_row.py b/discord/ui/action_row.py index aa38df9557..c02d9674f1 100644 --- a/discord/ui/action_row.py +++ b/discord/ui/action_row.py @@ -91,16 +91,21 @@ def _add_component_from_item(self, item: Item): def _set_components(self, items: list[Item]): self._underlying.components.clear() - if not any(isinstance(b, Select) for b in self.children): + for item in items: + self._add_component_from_item(item) + + def _reorder(self): + items = self.children + if not any([isinstance(b, Select) for b in self.children]): a, b = [], [] for i in items: if i.priority is None: b.append(i) else: a.append(i) - items = sorted(a, key=lambda b: b.priority) + b - for item in items: - self._add_component_from_item(item) + items = sorted(a, key=lambda c: c.priority) + b + self.children = items + self._set_components(items) def add_item(self, item: Item) -> Self: """Adds an item to the action row. @@ -350,7 +355,7 @@ def walk_items(self) -> Iterator[Item]: yield from self.children def to_component_dict(self) -> ActionRowPayload: - self._set_components(self.children) + self._reorder() return self._underlying.to_dict() @classmethod From 20aae0fa9d2d295e56b04abbaa3593084e3f4820 Mon Sep 17 00:00:00 2001 From: UK <41271523+NeloBlivion@users.noreply.github.com> Date: Mon, 22 Sep 2025 18:45:29 -0400 Subject: [PATCH 51/99] no --- discord/ui/action_row.py | 21 +-------------------- discord/ui/button.py | 16 ---------------- 2 files changed, 1 insertion(+), 36 deletions(-) diff --git a/discord/ui/action_row.py b/discord/ui/action_row.py index c02d9674f1..8ea60052de 100644 --- a/discord/ui/action_row.py +++ b/discord/ui/action_row.py @@ -94,19 +94,6 @@ def _set_components(self, items: list[Item]): for item in items: self._add_component_from_item(item) - def _reorder(self): - items = self.children - if not any([isinstance(b, Select) for b in self.children]): - a, b = [], [] - for i in items: - if i.priority is None: - b.append(i) - else: - a.append(i) - items = sorted(a, key=lambda c: c.priority) + b - self.children = items - self._set_components(items) - def add_item(self, item: Item) -> Self: """Adds an item to the action row. @@ -183,7 +170,6 @@ def add_button( emoji: str | GuildEmoji | AppEmoji | PartialEmoji | None = None, sku_id: int | None = None, id: int | None = None, - priority: int | None = None, ) -> Self: """Adds a :class:`Button` to the action row. @@ -209,10 +195,6 @@ def add_button( The ID of the SKU this button refers to. id: Optional[:class:`int`] The button's ID. - priority: Optional[:class:`int`] - An integer greater than 0. If specified, decides the position - of the button in this row instead of going by order of addition. The lower this number, the earlier its position. - This ActionRow's children will be reordered when the View containing it is sent. A priority of ``None`` will be ordered after any specified priority. """ button = Button( @@ -224,7 +206,6 @@ def add_button( emoji=emoji, sku_id=sku_id, id=id, - priority=priority, ) return self.add_item(button) @@ -355,7 +336,7 @@ def walk_items(self) -> Iterator[Item]: yield from self.children def to_component_dict(self) -> ActionRowPayload: - self._reorder() + self._set_components() return self._underlying.to_dict() @classmethod diff --git a/discord/ui/button.py b/discord/ui/button.py index aa3faae5e4..57fab4b2f2 100644 --- a/discord/ui/button.py +++ b/discord/ui/button.py @@ -82,10 +82,6 @@ class Button(Item[V]): id: Optional[:class:`int`] The button's ID. - priority: Optional[:class:`int`] - Only works in :class:`ActionRow`. Any integer greater than 0. If specified, decides the position - of the button in this row instead of going by order of addition. The lower this number, the earlier its position. - The ActionRow's children will be reordered when the View containing this button is sent. """ __item_repr_attributes__: tuple[str, ...] = ( @@ -98,7 +94,6 @@ class Button(Item[V]): "row", "custom_id", "id", - "priority", ) def __init__( @@ -113,7 +108,6 @@ def __init__( sku_id: int | None = None, row: int | None = None, id: int | None = None, - priority: int | None = None, ): super().__init__() if label and len(str(label)) > 80: @@ -126,14 +120,11 @@ def __init__( 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 priority and (priority < 0 or not isinstance(priority, int)): - raise ValueError("priority must be an integer greater than 0") 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.priority: int | None = priority self._provided_custom_id = custom_id is not None if url is None and custom_id is None and sku_id is None: @@ -303,7 +294,6 @@ def button( emoji: str | GuildEmoji | AppEmoji | PartialEmoji | None = None, row: int | None = None, id: int | None = None, - priority: int | None = None, ) -> Callable[[ItemCallbackType[Button[V]]], Button[V]]: """A decorator that attaches a button to a component. @@ -342,11 +332,6 @@ def button( .. warning:: This parameter does not work in :class:`ActionRow`. - - priority: Optional[:class:`int`] - Only works in :class:`ActionRow`. Any integer greater than 0. If specified, decides the position - of the button in this row instead of going by order of addition. The lower this number, the earlier its position. - The ActionRow's children will be reordered when the View containing it is sent. A priority of ``None`` will be ordered after any specified priority. """ def decorator(func: ItemCallbackType) -> ItemCallbackType: @@ -363,7 +348,6 @@ def decorator(func: ItemCallbackType) -> ItemCallbackType: "emoji": emoji, "row": row, "id": id, - "priority": priority, } return func From a846b4379e92a6192ae65602d83aa8c770341e3a Mon Sep 17 00:00:00 2001 From: UK <41271523+NeloBlivion@users.noreply.github.com> Date: Tue, 23 Sep 2025 12:29:47 -0400 Subject: [PATCH 52/99] update examples --- examples/modal_dialogs.py | 30 ++++++++++++++++++++---------- examples/views/new_components.py | 18 +++++++++++------- 2 files changed, 31 insertions(+), 17 deletions(-) diff --git a/examples/modal_dialogs.py b/examples/modal_dialogs.py index 4d10ddab0c..d4d20422d0 100644 --- a/examples/modal_dialogs.py +++ b/examples/modal_dialogs.py @@ -11,31 +11,41 @@ ) -class MyModal(discord.ui.Modal): +class MyModal(discord.ui.DesignerModal): def __init__(self, *args, **kwargs) -> None: - super().__init__( + first_input = discord.ui.Label( discord.ui.InputText( - label="Short Input", placeholder="Placeholder Test", ), + label="Short Input" + ) + second_input = discord.ui.Label( discord.ui.InputText( - label="Longer Input", + placeholder="Placeholder Test", value="Longer Value\nSuper Long Value", style=discord.InputTextStyle.long, - description="You can also describe the purpose of this input.", ), - discord.ui.TextDisplay("# Personal Questions"), + label="Longer Input", + description="You can also describe the purpose of this input.", + ) + select = discord.ui.Label( discord.ui.Select( - label="What's your favorite color?", placeholder="Select a color", options=[ discord.SelectOption(label="Red", emoji="🟥"), discord.SelectOption(label="Green", emoji="🟩"), discord.SelectOption(label="Blue", emoji="🟦"), ], - description="If it is not listed, skip this question.", required=False, ), + label="What's your favorite color?", + description="If it is not listed, skip this question.", + ) + super().__init__( + first_input, + second_input, + discord.ui.TextDisplay("# Personal Questions"), # TextDisplay does NOT use Label + select, *args, **kwargs, ) @@ -45,10 +55,10 @@ async def callback(self, interaction: discord.Interaction): title="Your Modal Results", fields=[ discord.EmbedField( - name="First Input", value=self.children[0].value, inline=False + name="First Input", value=self.children[0].item.value, inline=False ), discord.EmbedField( - name="Second Input", value=self.children[1].value, inline=False + name="Second Input", value=self.children[1].item.value, inline=False ), ], color=discord.Color.random(), diff --git a/examples/views/new_components.py b/examples/views/new_components.py index 53a05f7435..9e19e39c10 100644 --- a/examples/views/new_components.py +++ b/examples/views/new_components.py @@ -19,12 +19,19 @@ Separator, TextDisplay, Thumbnail, - View, + DesignerView, + ActionRow, button, ) +class MyRow(ActionRow): -class MyView(View): + @button(label="Delete Message", style=ButtonStyle.red, id=200) + async def delete_button(self, button: Button, interaction: Interaction): + await interaction.response.defer(invisible=True) + await interaction.message.delete() + +class MyView(DesignerView): def __init__(self, user: User): super().__init__(timeout=30) text1 = TextDisplay("### This is a sample `TextDisplay` in a `Section`.") @@ -55,11 +62,8 @@ def __init__(self, user: User): self.add_item( TextDisplay("Above is a `MediaGallery` containing two `MediaGalleryItem`s.") ) - - @button(label="Delete Message", style=ButtonStyle.red, id=200) - async def delete_button(self, button: Button, interaction: Interaction): - await interaction.response.defer(invisible=True) - await interaction.message.delete() + row = MyRow() + self.add_item(row) async def on_timeout(self): self.get_item(200).disabled = True From 7caf4f5d2a0697a5b6c377aa8e29aafd4dc0b7a4 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Tue, 23 Sep 2025 16:30:16 +0000 Subject: [PATCH 53/99] style(pre-commit): auto fixes from pre-commit.com hooks --- examples/modal_dialogs.py | 6 ++++-- examples/views/new_components.py | 6 ++++-- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/examples/modal_dialogs.py b/examples/modal_dialogs.py index d4d20422d0..dda4bf2b9b 100644 --- a/examples/modal_dialogs.py +++ b/examples/modal_dialogs.py @@ -17,7 +17,7 @@ def __init__(self, *args, **kwargs) -> None: discord.ui.InputText( placeholder="Placeholder Test", ), - label="Short Input" + label="Short Input", ) second_input = discord.ui.Label( discord.ui.InputText( @@ -44,7 +44,9 @@ def __init__(self, *args, **kwargs) -> None: super().__init__( first_input, second_input, - discord.ui.TextDisplay("# Personal Questions"), # TextDisplay does NOT use Label + discord.ui.TextDisplay( + "# Personal Questions" + ), # TextDisplay does NOT use Label select, *args, **kwargs, diff --git a/examples/views/new_components.py b/examples/views/new_components.py index 9e19e39c10..e022dab089 100644 --- a/examples/views/new_components.py +++ b/examples/views/new_components.py @@ -11,19 +11,20 @@ User, ) from discord.ui import ( + ActionRow, Button, Container, + DesignerView, MediaGallery, Section, Select, Separator, TextDisplay, Thumbnail, - DesignerView, - ActionRow, button, ) + class MyRow(ActionRow): @button(label="Delete Message", style=ButtonStyle.red, id=200) @@ -31,6 +32,7 @@ async def delete_button(self, button: Button, interaction: Interaction): await interaction.response.defer(invisible=True) await interaction.message.delete() + class MyView(DesignerView): def __init__(self, user: User): super().__init__(timeout=30) From 0060e0fbde2fddd039f4efe1b8f0a72f6bff96e8 Mon Sep 17 00:00:00 2001 From: UK <41271523+NeloBlivion@users.noreply.github.com> Date: Tue, 23 Sep 2025 12:33:31 -0400 Subject: [PATCH 54/99] remove v2 from weights --- discord/ui/view.py | 16 ---------------- 1 file changed, 16 deletions(-) diff --git a/discord/ui/view.py b/discord/ui/view.py index 45470ced69..bf3e2f5aa7 100644 --- a/discord/ui/view.py +++ b/discord/ui/view.py @@ -162,11 +162,6 @@ def find_open_space(self, item: Item[V]) -> int: raise ValueError("could not find open space for item") def add_item(self, item: Item[V]) -> None: - if ( - item._underlying.is_v2() or not self.fits_legacy(item) - ) and not self.requires_v2(): - self.weights.extend([0, 0, 0, 0, 0] * 7) - if item.row is not None: total = self.weights[item.row] + item.width if total > 5: @@ -184,21 +179,10 @@ def remove_item(self, item: Item[V]) -> None: if item._rendered_row is not None: self.weights[item._rendered_row] -= item.width item._rendered_row = None - if len(self.weights) > 5: # attempt to downgrade view - if all(x == 0 for x in self.weights[5:]): - self.weights = self.weights[:5] def clear(self) -> None: self.weights = [0, 0, 0, 0, 0] - def requires_v2(self) -> bool: - return sum(w > 0 for w in self.weights) > 5 or len(self.weights) > 5 - - def fits_legacy(self, item) -> bool: - if item.row is not None: - return item.row <= 4 - return self.weights[-1] + item.width <= 5 - class BaseView(ItemInterface): """The base class for UI views used in messages.""" From 8308615fe55bb57afa8841377f4fe24b2296e28b Mon Sep 17 00:00:00 2001 From: UK <41271523+NeloBlivion@users.noreply.github.com> Date: Tue, 23 Sep 2025 14:10:35 -0400 Subject: [PATCH 55/99] misc --- discord/ui/action_row.py | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/discord/ui/action_row.py b/discord/ui/action_row.py index 8ea60052de..8d52863162 100644 --- a/discord/ui/action_row.py +++ b/discord/ui/action_row.py @@ -87,10 +87,10 @@ def __init__( self.add_item(i) def _add_component_from_item(self, item: Item): - self._underlying.components.append(item._underlying) + self._underlying.children.append(item._underlying) def _set_components(self, items: list[Item]): - self._underlying.components.clear() + self._underlying.children.clear() for item in items: self._add_component_from_item(item) @@ -174,7 +174,7 @@ def add_button( """Adds a :class:`Button` to the action row. To append a pre-existing :class:`Button`, use the - :meth:`add_item` method, instead. + :meth:`add_item` method instead. Parameters ---------- @@ -223,7 +223,10 @@ def add_select( disabled: bool = False, id: int | None = None, ) -> Self: - """Adds a :class:`TextDisplay` to the container. + """Adds a :class:`Select` to the container. + + To append a pre-existing :class:`Select`, use the + :meth:`add_item` method instead. Parameters ---------- From 5410e008bd5a83f96ce74f066cef6ebe75b11288 Mon Sep 17 00:00:00 2001 From: UK <41271523+NeloBlivion@users.noreply.github.com> Date: Tue, 23 Sep 2025 14:15:50 -0400 Subject: [PATCH 56/99] adjust Label args (first arg label) --- discord/ui/label.py | 2 +- examples/modal_dialogs.py | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/discord/ui/label.py b/discord/ui/label.py index b3371c8999..ea4dcaf66a 100644 --- a/discord/ui/label.py +++ b/discord/ui/label.py @@ -58,9 +58,9 @@ class Label(Item[V]): def __init__( self, + label: str, item: Item = None, *, - label: str, description: str | None = None, id: int | None = None, ): diff --git a/examples/modal_dialogs.py b/examples/modal_dialogs.py index dda4bf2b9b..0320681811 100644 --- a/examples/modal_dialogs.py +++ b/examples/modal_dialogs.py @@ -14,21 +14,22 @@ class MyModal(discord.ui.DesignerModal): def __init__(self, *args, **kwargs) -> None: first_input = discord.ui.Label( + "Short Input", discord.ui.InputText( placeholder="Placeholder Test", ), - label="Short Input", ) second_input = discord.ui.Label( + "Longer Input", discord.ui.InputText( placeholder="Placeholder Test", value="Longer Value\nSuper Long Value", style=discord.InputTextStyle.long, ), - label="Longer Input", description="You can also describe the purpose of this input.", ) select = discord.ui.Label( + "What's your favorite color?", discord.ui.Select( placeholder="Select a color", options=[ @@ -38,7 +39,6 @@ def __init__(self, *args, **kwargs) -> None: ], required=False, ), - label="What's your favorite color?", description="If it is not listed, skip this question.", ) super().__init__( From d4dc1c5e6e6fc24327fbc07926e42c1bacc6eea4 Mon Sep 17 00:00:00 2001 From: UK <41271523+NeloBlivion@users.noreply.github.com> Date: Tue, 23 Sep 2025 14:28:00 -0400 Subject: [PATCH 57/99] add select overloads --- discord/ui/action_row.py | 57 +++++++++++++++++++++++++++++++++++++++- discord/ui/label.py | 57 +++++++++++++++++++++++++++++++++++++++- discord/ui/select.py | 4 +-- 3 files changed, 114 insertions(+), 4 deletions(-) diff --git a/discord/ui/action_row.py b/discord/ui/action_row.py index 8d52863162..03c37ee928 100644 --- a/discord/ui/action_row.py +++ b/discord/ui/action_row.py @@ -1,10 +1,11 @@ from __future__ import annotations from functools import partial +from collections.abc import Sequence from typing import TYPE_CHECKING, ClassVar, Iterator, TypeVar from ..components import ActionRow as ActionRowComponent -from ..components import SelectOption, _component_factory +from ..components import SelectOption, SelectDefaultValue, _component_factory from ..enums import ButtonStyle, ChannelType, ComponentType from ..utils import find, get from .button import Button @@ -210,6 +211,53 @@ def add_button( 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, @@ -222,6 +270,7 @@ def add_select( 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 container. @@ -256,6 +305,11 @@ def add_select( 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( @@ -268,6 +322,7 @@ def add_select( channel_types=channel_types or [], disabled=disabled, id=id, + default_values=default_values ) return self.add_item(select) diff --git a/discord/ui/label.py b/discord/ui/label.py index ea4dcaf66a..1caf79ea71 100644 --- a/discord/ui/label.py +++ b/discord/ui/label.py @@ -1,9 +1,10 @@ from __future__ import annotations from typing import TYPE_CHECKING, Iterator, TypeVar +from collections.abc import Sequence from ..components import Label as LabelComponent -from ..components import SelectOption, _component_factory +from ..components import SelectOption, SelectDefaultValue, _component_factory from ..enums import ButtonStyle, ChannelType, ComponentType, InputTextStyle from ..utils import find, get from .button import Button @@ -184,6 +185,53 @@ def set_input_text( 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, @@ -196,6 +244,7 @@ def set_select( channel_types: list[ChannelType] | None = None, required: bool = True, id: int | None = None, + default_values: Sequence[SelectDefaultValue] | None = ..., ) -> Self: """Set this label's item to a select menu. @@ -227,6 +276,11 @@ def set_select( 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( @@ -239,6 +293,7 @@ def set_select( channel_types=channel_types or [], required=required, id=id, + default_values=default_values ) return self.set_item(select) diff --git a/discord/ui/select.py b/discord/ui/select.py index 5d641baf37..b4789eff7a 100644 --- a/discord/ui/select.py +++ b/discord/ui/select.py @@ -134,11 +134,11 @@ class Select(Generic[V, ST], Item[V]): 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). + ordering. The row number must be between 0 and 4 (i.e. zero indexed). Does not work in :class:`ActionRow` or :class:`Label`. id: Optional[:class:`int`] The select menu's ID. required: Optional[:class:`bool`] - Whether the select is required or not. Only useable in modals. Defaults to ``True`` in modals. + Whether the select is required or not. Only useable when added to :class:`Label` for modals. Defaults to ``True`` in modals. .. versionadded:: 2.7 default_values: Optional[Sequence[Union[:class:`discord.SelectDefaultValue`, :class:`discord.abc.Snowflake`]]] From b619025769d3675e2d0839acdd04cc8a7347c390 Mon Sep 17 00:00:00 2001 From: UK <41271523+NeloBlivion@users.noreply.github.com> Date: Tue, 23 Sep 2025 14:28:56 -0400 Subject: [PATCH 58/99] actionrow clarification --- discord/ui/action_row.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/discord/ui/action_row.py b/discord/ui/action_row.py index 03c37ee928..fd9348ab27 100644 --- a/discord/ui/action_row.py +++ b/discord/ui/action_row.py @@ -29,7 +29,7 @@ class ActionRow(Item[V]): - """Represents a UI Action Row used in :class:`discord.ui.View`. + """Represents a UI Action Row used in :class:`discord.ui.DesignerView`. The items supported are as follows: From f90a3bc01e04d609ea1b52a2b9843e99e195ae8d Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Tue, 23 Sep 2025 18:29:14 +0000 Subject: [PATCH 59/99] style(pre-commit): auto fixes from pre-commit.com hooks --- discord/ui/action_row.py | 6 +++--- discord/ui/label.py | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/discord/ui/action_row.py b/discord/ui/action_row.py index fd9348ab27..609557bfd6 100644 --- a/discord/ui/action_row.py +++ b/discord/ui/action_row.py @@ -1,11 +1,11 @@ from __future__ import annotations -from functools import partial from collections.abc import Sequence +from functools import partial from typing import TYPE_CHECKING, ClassVar, Iterator, TypeVar from ..components import ActionRow as ActionRowComponent -from ..components import SelectOption, SelectDefaultValue, _component_factory +from ..components import SelectDefaultValue, SelectOption, _component_factory from ..enums import ButtonStyle, ChannelType, ComponentType from ..utils import find, get from .button import Button @@ -322,7 +322,7 @@ def add_select( channel_types=channel_types or [], disabled=disabled, id=id, - default_values=default_values + default_values=default_values, ) return self.add_item(select) diff --git a/discord/ui/label.py b/discord/ui/label.py index 1caf79ea71..b424e2d1a5 100644 --- a/discord/ui/label.py +++ b/discord/ui/label.py @@ -1,10 +1,10 @@ from __future__ import annotations -from typing import TYPE_CHECKING, Iterator, TypeVar from collections.abc import Sequence +from typing import TYPE_CHECKING, Iterator, TypeVar from ..components import Label as LabelComponent -from ..components import SelectOption, SelectDefaultValue, _component_factory +from ..components import SelectDefaultValue, SelectOption, _component_factory from ..enums import ButtonStyle, ChannelType, ComponentType, InputTextStyle from ..utils import find, get from .button import Button @@ -293,7 +293,7 @@ def set_select( channel_types=channel_types or [], required=required, id=id, - default_values=default_values + default_values=default_values, ) return self.set_item(select) From 35e4cb1b832f82dd41dfb0f396d3f296bc96c304 Mon Sep 17 00:00:00 2001 From: UK <41271523+NeloBlivion@users.noreply.github.com> Date: Tue, 23 Sep 2025 14:29:38 -0400 Subject: [PATCH 60/99] all items in actionrow have the disabled attribute --- discord/ui/action_row.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/discord/ui/action_row.py b/discord/ui/action_row.py index 609557bfd6..9307d53741 100644 --- a/discord/ui/action_row.py +++ b/discord/ui/action_row.py @@ -376,9 +376,7 @@ def enable_all_items(self, *, exclusions: list[Item] | None = None) -> Self: A list of items in `self.children` to not enable. """ for item in self.walk_items(): - if hasattr(item, "disabled") and ( - exclusions is None or item not in exclusions - ): + if exclusions is None or item not in exclusions item.disabled = False return self From 65a111656f603ec06e5e6d395e4a91b7ed66a1f5 Mon Sep 17 00:00:00 2001 From: UK <41271523+NeloBlivion@users.noreply.github.com> Date: Wed, 24 Sep 2025 06:13:10 -0400 Subject: [PATCH 61/99] fixes --- discord/ui/action_row.py | 2 +- discord/ui/label.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/discord/ui/action_row.py b/discord/ui/action_row.py index 9307d53741..f4aa3232c1 100644 --- a/discord/ui/action_row.py +++ b/discord/ui/action_row.py @@ -376,7 +376,7 @@ def enable_all_items(self, *, exclusions: list[Item] | None = None) -> Self: 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 + if exclusions is None or item not in exclusions: item.disabled = False return self diff --git a/discord/ui/label.py b/discord/ui/label.py index b424e2d1a5..b364bb8778 100644 --- a/discord/ui/label.py +++ b/discord/ui/label.py @@ -1,7 +1,7 @@ from __future__ import annotations from collections.abc import Sequence -from typing import TYPE_CHECKING, Iterator, TypeVar +from typing import TYPE_CHECKING, Iterator, TypeVar, Literal, overload from ..components import Label as LabelComponent from ..components import SelectDefaultValue, SelectOption, _component_factory From e453e0146865765072664c669dc6194595df8908 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Wed, 24 Sep 2025 10:13:39 +0000 Subject: [PATCH 62/99] style(pre-commit): auto fixes from pre-commit.com hooks --- discord/ui/label.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/discord/ui/label.py b/discord/ui/label.py index b364bb8778..7288817a98 100644 --- a/discord/ui/label.py +++ b/discord/ui/label.py @@ -1,7 +1,7 @@ from __future__ import annotations from collections.abc import Sequence -from typing import TYPE_CHECKING, Iterator, TypeVar, Literal, overload +from typing import TYPE_CHECKING, Iterator, Literal, TypeVar, overload from ..components import Label as LabelComponent from ..components import SelectDefaultValue, SelectOption, _component_factory From e9ac8bc46c398a6596a6bff1598f9e16b130fc63 Mon Sep 17 00:00:00 2001 From: UK <41271523+NeloBlivion@users.noreply.github.com> Date: Thu, 25 Sep 2025 15:03:50 -0400 Subject: [PATCH 63/99] more imports --- discord/ui/action_row.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/discord/ui/action_row.py b/discord/ui/action_row.py index f4aa3232c1..0dc30502da 100644 --- a/discord/ui/action_row.py +++ b/discord/ui/action_row.py @@ -2,7 +2,7 @@ from collections.abc import Sequence from functools import partial -from typing import TYPE_CHECKING, ClassVar, Iterator, TypeVar +from typing import TYPE_CHECKING, ClassVar, Iterator, TypeVar, Literal, overload from ..components import ActionRow as ActionRowComponent from ..components import SelectDefaultValue, SelectOption, _component_factory From b2ed0d11c0ae888986295d9ff04a4810ec4fd6a9 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Thu, 25 Sep 2025 19:04:18 +0000 Subject: [PATCH 64/99] style(pre-commit): auto fixes from pre-commit.com hooks --- discord/ui/action_row.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/discord/ui/action_row.py b/discord/ui/action_row.py index 0dc30502da..01514d98c8 100644 --- a/discord/ui/action_row.py +++ b/discord/ui/action_row.py @@ -2,7 +2,7 @@ from collections.abc import Sequence from functools import partial -from typing import TYPE_CHECKING, ClassVar, Iterator, TypeVar, Literal, overload +from typing import TYPE_CHECKING, ClassVar, Iterator, Literal, TypeVar, overload from ..components import ActionRow as ActionRowComponent from ..components import SelectDefaultValue, SelectOption, _component_factory From 9ca373a6644cfd10cf3ebfdb2bbf49714e0b2a32 Mon Sep 17 00:00:00 2001 From: UK <41271523+NeloBlivion@users.noreply.github.com> Date: Thu, 25 Sep 2025 15:27:04 -0400 Subject: [PATCH 65/99] docs and typing cleanup --- discord/abc.py | 12 ++++++------ discord/channel.py | 6 +++--- discord/client.py | 16 ++++++++-------- discord/interactions.py | 22 +++++++++++----------- discord/message.py | 10 +++++----- discord/state.py | 8 ++++---- discord/ui/action_row.py | 4 ++-- discord/ui/button.py | 6 +++--- discord/ui/container.py | 4 ++-- discord/ui/file.py | 4 ++-- discord/ui/item.py | 12 ++++++------ discord/ui/label.py | 12 +++--------- discord/ui/media_gallery.py | 4 ++-- discord/ui/section.py | 4 ++-- discord/ui/select.py | 6 +++--- discord/ui/separator.py | 4 ++-- discord/ui/text_display.py | 6 +++--- discord/ui/thumbnail.py | 4 ++-- discord/ui/view.py | 2 +- discord/webhook/async_.py | 20 ++++++++++---------- docs/api/ui_kit.rst | 34 ++++++++++++++++++++++++++++++++++ 21 files changed, 114 insertions(+), 86 deletions(-) diff --git a/discord/abc.py b/discord/abc.py index 54b75bea85..da8e5d37cc 100644 --- a/discord/abc.py +++ b/discord/abc.py @@ -95,7 +95,7 @@ from .types.channel import GuildChannel as GuildChannelPayload from .types.channel import OverwriteType from .types.channel import PermissionOverwrite as PermissionOverwritePayload - from .ui.view import View + from .ui.view import BaseView from .user import ClientUser PartialMessageableChannel = Union[ @@ -1355,7 +1355,7 @@ async def send( allowed_mentions: AllowedMentions = ..., reference: Message | MessageReference | PartialMessage = ..., mention_author: bool = ..., - view: View = ..., + view: BaseView = ..., poll: Poll = ..., suppress: bool = ..., silent: bool = ..., @@ -1376,7 +1376,7 @@ async def send( allowed_mentions: AllowedMentions = ..., reference: Message | MessageReference | PartialMessage = ..., mention_author: bool = ..., - view: View = ..., + view: BaseView = ..., poll: Poll = ..., suppress: bool = ..., silent: bool = ..., @@ -1397,7 +1397,7 @@ async def send( allowed_mentions: AllowedMentions = ..., reference: Message | MessageReference | PartialMessage = ..., mention_author: bool = ..., - view: View = ..., + view: BaseView = ..., poll: Poll = ..., suppress: bool = ..., silent: bool = ..., @@ -1418,7 +1418,7 @@ async def send( allowed_mentions: AllowedMentions = ..., reference: Message | MessageReference | PartialMessage = ..., mention_author: bool = ..., - view: View = ..., + view: BaseView = ..., poll: Poll = ..., suppress: bool = ..., silent: bool = ..., @@ -1509,7 +1509,7 @@ async def send( If set, overrides the :attr:`~discord.AllowedMentions.replied_user` attribute of ``allowed_mentions``. .. versionadded:: 1.6 - view: :class:`discord.ui.View` + view: :class:`discord.ui.BaseView` A Discord UI View to add to the message. embeds: List[:class:`~discord.Embed`] A list of embeds to upload. Must be a maximum of 10. diff --git a/discord/channel.py b/discord/channel.py index 1771b9c69a..0d21f38013 100644 --- a/discord/channel.py +++ b/discord/channel.py @@ -107,7 +107,7 @@ from .types.channel import VoiceChannelEffectSendEvent as VoiceChannelEffectSend from .types.snowflake import SnowflakeList from .types.threads import ThreadArchiveDuration - from .ui.view import View + from .ui.view import BaseView from .user import BaseUser, ClientUser, User from .webhook import Webhook @@ -1217,7 +1217,7 @@ async def create_thread( delete_message_after: float | None = None, nonce: int | str | None = None, allowed_mentions: AllowedMentions | None = None, - view: View | None = None, + view: BaseView | None = None, applied_tags: list[ForumTag] | None = None, suppress: bool = False, silent: bool = False, @@ -1262,7 +1262,7 @@ async def create_thread( to the object, otherwise it uses the attributes set in :attr:`~discord.Client.allowed_mentions`. If no object is passed at all then the defaults given by :attr:`~discord.Client.allowed_mentions` are used instead. - view: :class:`discord.ui.View` + view: :class:`discord.ui.BaseView` A Discord UI View to add to the message. applied_tags: List[:class:`discord.ForumTag`] A list of tags to apply to the new thread. diff --git a/discord/client.py b/discord/client.py index ed6cdb8991..8011ca0b92 100644 --- a/discord/client.py +++ b/discord/client.py @@ -59,7 +59,7 @@ from .sticker import GuildSticker, StandardSticker, StickerPack, _sticker_factory from .template import Template from .threads import Thread -from .ui.view import View +from .ui.view import BaseView from .user import ClientUser, User from .utils import MISSING from .voice_client import VoiceClient @@ -552,7 +552,7 @@ async def on_view_error( The default view error handler provided by the client. - This only fires for a view if you did not define its :func:`~discord.ui.View.on_error`. + This only fires for a view if you did not define its :func:`~discord.ui.BaseView.on_error`. Parameters ---------- @@ -2037,8 +2037,8 @@ async def create_dm(self, user: Snowflake) -> DMChannel: data = await state.http.start_private_message(user.id) return state.add_dm_channel(data) - def add_view(self, view: View, *, message_id: int | None = None) -> None: - """Registers a :class:`~discord.ui.View` for persistent listening. + def add_view(self, view: BaseView, *, message_id: int | None = None) -> None: + """Registers a :class:`~discord.ui.BaseView` for persistent listening. This method should be used for when a view is comprised of components that last longer than the lifecycle of the program. @@ -2047,7 +2047,7 @@ def add_view(self, view: View, *, message_id: int | None = None) -> None: Parameters ---------- - view: :class:`discord.ui.View` + view: :class:`discord.ui.BaseView` The view to register for dispatching. message_id: Optional[:class:`int`] The message ID that the view is attached to. This is currently used to @@ -2063,8 +2063,8 @@ def add_view(self, view: View, *, message_id: int | None = None) -> None: and all their components have an explicitly provided ``custom_id``. """ - if not isinstance(view, View): - raise TypeError(f"expected an instance of View not {view.__class__!r}") + if not isinstance(view, BaseView): + raise TypeError(f"expected an instance of BaseView not {view.__class__!r}") if not view.is_persistent(): raise ValueError( @@ -2075,7 +2075,7 @@ def add_view(self, view: View, *, message_id: int | None = None) -> None: self._connection.store_view(view, message_id) @property - def persistent_views(self) -> Sequence[View]: + def persistent_views(self) -> Sequence[BaseView]: """A sequence of persistent views added to the client. .. versionadded:: 2.0 diff --git a/discord/interactions.py b/discord/interactions.py index c94c2bcea4..8703306dd6 100644 --- a/discord/interactions.py +++ b/discord/interactions.py @@ -89,7 +89,7 @@ from .types.interactions import InteractionMetadata as InteractionMetadataPayload from .types.interactions import MessageInteraction as MessageInteractionPayload from .ui.modal import Modal - from .ui.view import View + from .ui.view import BaseView InteractionChannel = Union[ VoiceChannel, @@ -164,7 +164,7 @@ class Interaction: The command that this interaction belongs to. .. versionadded:: 2.7 - view: Optional[:class:`View`] + view: Optional[:class:`BaseView`] The view that this interaction belongs to. .. versionadded:: 2.7 @@ -257,7 +257,7 @@ def _from_data(self, data: InteractionPayload): ) self.command: ApplicationCommand | None = None - self.view: View | None = None + self.view: BaseView | None = None self.modal: Modal | None = None self.attachment_size_limit: int = data.get("attachment_size_limit") @@ -522,7 +522,7 @@ async def edit_original_response( file: File = MISSING, files: list[File] = MISSING, attachments: list[Attachment] = MISSING, - view: View | None = MISSING, + view: BaseView | None = MISSING, allowed_mentions: AllowedMentions | None = None, delete_after: float | None = None, suppress: bool = False, @@ -557,7 +557,7 @@ async def edit_original_response( allowed_mentions: :class:`AllowedMentions` Controls the mentions being processed in this message. See :meth:`.abc.Messageable.send` for more information. - view: Optional[:class:`~discord.ui.View`] + view: Optional[:class:`~discord.ui.BaseView`] The updated view to update this message with. If ``None`` is passed then the view is removed. delete_after: Optional[:class:`float`] @@ -947,7 +947,7 @@ async def send_message( *, embed: Embed = None, embeds: list[Embed] = None, - view: View = None, + view: BaseView = None, tts: bool = False, ephemeral: bool = False, allowed_mentions: AllowedMentions = None, @@ -972,7 +972,7 @@ async def send_message( ``embeds`` parameter. tts: :class:`bool` Indicates if the message should be sent using text-to-speech. - view: :class:`discord.ui.View` + view: :class:`discord.ui.BaseView` The view to send with the message. ephemeral: :class:`bool` Indicates if the message should only be visible to the user who started the interaction. @@ -1128,7 +1128,7 @@ async def edit_message( file: File = MISSING, files: list[File] = MISSING, attachments: list[Attachment] = MISSING, - view: View | None = MISSING, + view: BaseView | None = MISSING, delete_after: float | None = None, suppress: bool | None = MISSING, allowed_mentions: AllowedMentions | None = None, @@ -1155,7 +1155,7 @@ async def edit_message( attachments: List[:class:`Attachment`] A list of attachments to keep in the message. If ``[]`` is passed then all attachments are removed. - view: Optional[:class:`~discord.ui.View`] + view: Optional[:class:`~discord.ui.BaseView`] The updated view to update this message with. If ``None`` is passed then the view is removed. delete_after: Optional[:class:`float`] @@ -1486,7 +1486,7 @@ async def edit( file: File = MISSING, files: list[File] = MISSING, attachments: list[Attachment] = MISSING, - view: View | None = MISSING, + view: BaseView | None = MISSING, allowed_mentions: AllowedMentions | None = None, delete_after: float | None = None, suppress: bool | None = MISSING, @@ -1515,7 +1515,7 @@ async def edit( allowed_mentions: :class:`AllowedMentions` Controls the mentions being processed in this message. See :meth:`.abc.Messageable.send` for more information. - view: Optional[:class:`~discord.ui.View`] + view: Optional[:class:`~discord.ui.BaseView`] The updated view to update this message with. If ``None`` is passed then the view is removed. delete_after: Optional[:class:`float`] diff --git a/discord/message.py b/discord/message.py index e476ae8fcf..117050d2d3 100644 --- a/discord/message.py +++ b/discord/message.py @@ -92,7 +92,7 @@ from .types.snowflake import SnowflakeList from .types.threads import ThreadArchiveDuration from .types.user import User as UserPayload - from .ui.view import View + from .ui.view import BaseView from .user import User MR = TypeVar("MR", bound="MessageReference") @@ -1660,7 +1660,7 @@ async def edit( suppress: bool = ..., delete_after: float | None = ..., allowed_mentions: AllowedMentions | None = ..., - view: View | None = ..., + view: BaseView | None = ..., ) -> Message: ... async def edit( @@ -1674,7 +1674,7 @@ async def edit( suppress: bool = MISSING, delete_after: float | None = None, allowed_mentions: AllowedMentions | None = MISSING, - view: View | None = MISSING, + view: BaseView | None = MISSING, ) -> Message: """|coro| @@ -1723,7 +1723,7 @@ async def edit( are used instead. .. versionadded:: 1.4 - view: Optional[:class:`~discord.ui.View`] + view: Optional[:class:`~discord.ui.BaseView`] The updated view to update this message with. If ``None`` is passed then the view is removed. @@ -2412,7 +2412,7 @@ async def edit(self, **fields: Any) -> Message | None: to the object, otherwise it uses the attributes set in :attr:`~discord.Client.allowed_mentions`. If no object is passed at all then the defaults given by :attr:`~discord.Client.allowed_mentions` are used instead. - view: Optional[:class:`~discord.ui.View`] + view: Optional[:class:`~discord.ui.BaseView`] The updated view to update this message with. If ``None`` is passed then the view is removed. diff --git a/discord/state.py b/discord/state.py index dc982d43bb..dcb63f9e87 100644 --- a/discord/state.py +++ b/discord/state.py @@ -71,7 +71,7 @@ from .sticker import GuildSticker from .threads import Thread, ThreadMember from .ui.modal import Modal, ModalStore -from .ui.view import View, ViewStore +from .ui.view import BaseView, ViewStore from .user import ClientUser, User if TYPE_CHECKING: @@ -399,17 +399,17 @@ def store_sticker(self, guild: Guild, data: GuildStickerPayload) -> GuildSticker self._stickers[sticker_id] = sticker = GuildSticker(state=self, data=data) return sticker - def store_view(self, view: View, message_id: int | None = None) -> None: + def store_view(self, view: BaseView, message_id: int | None = None) -> None: self._view_store.add_view(view, message_id) def store_modal(self, modal: Modal, message_id: int) -> None: self._modal_store.add_modal(modal, message_id) - def prevent_view_updates_for(self, message_id: int) -> View | None: + def prevent_view_updates_for(self, message_id: int) -> BaseView | None: return self._view_store.remove_message_tracking(message_id) @property - def persistent_views(self) -> Sequence[View]: + def persistent_views(self) -> Sequence[BaseView]: return self._view_store.persistent_views @property diff --git a/discord/ui/action_row.py b/discord/ui/action_row.py index 01514d98c8..eeaa515702 100644 --- a/discord/ui/action_row.py +++ b/discord/ui/action_row.py @@ -21,11 +21,11 @@ from ..emoji import AppEmoji, GuildEmoji from ..partial_emoji import PartialEmoji, _EmojiTag from ..types.components import ActionRow as ActionRowPayload - from .view import View + from .view import DesignerView A = TypeVar("A", bound="ActionRow") -V = TypeVar("V", bound="View", covariant=True) +V = TypeVar("V", bound="DesignerView", covariant=True) class ActionRow(Item[V]): diff --git a/discord/ui/button.py b/discord/ui/button.py index 57fab4b2f2..320dca2005 100644 --- a/discord/ui/button.py +++ b/discord/ui/button.py @@ -41,10 +41,10 @@ if TYPE_CHECKING: from ..emoji import AppEmoji, GuildEmoji - from .view import View + from .view import BaseView B = TypeVar("B", bound="Button") -V = TypeVar("V", bound="View", covariant=True) +V = TypeVar("V", bound="BaseView", covariant=True) class Button(Item[V]): @@ -298,7 +298,7 @@ def button( """A decorator that attaches a button to a component. The function being decorated should have three parameters, ``self`` representing - the :class:`discord.ui.View`, the :class:`discord.ui.Button` being pressed and + 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:: diff --git a/discord/ui/container.py b/discord/ui/container.py index e289c4cc5b..33e9798420 100644 --- a/discord/ui/container.py +++ b/discord/ui/container.py @@ -25,11 +25,11 @@ from typing_extensions import Self from ..types.components import ContainerComponent as ContainerComponentPayload - from .view import View + from .view import DesignerView C = TypeVar("C", bound="Container") -V = TypeVar("V", bound="View", covariant=True) +V = TypeVar("V", bound="DesignerView", covariant=True) class Container(Item[V]): diff --git a/discord/ui/file.py b/discord/ui/file.py index 1c7a9c932d..aadde37bcd 100644 --- a/discord/ui/file.py +++ b/discord/ui/file.py @@ -11,11 +11,11 @@ if TYPE_CHECKING: from ..types.components import FileComponent as FileComponentPayload - from .view import View + from .view import DesignerView F = TypeVar("F", bound="File") -V = TypeVar("V", bound="View", covariant=True) +V = TypeVar("V", bound="DesignerView", covariant=True) class File(Item[V]): diff --git a/discord/ui/item.py b/discord/ui/item.py index d80128a0c0..4766b5e0dc 100644 --- a/discord/ui/item.py +++ b/discord/ui/item.py @@ -34,22 +34,22 @@ if TYPE_CHECKING: from ..components import Component from ..enums import ComponentType - from .view import View + from .view import BaseView I = TypeVar("I", bound="Item") -V = TypeVar("V", bound="View", covariant=True) +V = TypeVar("V", bound="BaseView", covariant=True) ItemCallbackType = Callable[[Any, I, Interaction], Coroutine[Any, Any, Any]] class Item(Generic[V]): """Represents the base UI item that all UI components inherit from. - The following are the original items: + 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: + 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` @@ -79,7 +79,7 @@ def __init__(self): # actually affect the intended purpose of this check because from_component is # only called upon edit and we're mainly interested during initial creation time. self._provided_custom_id: bool = False - self.parent: Item | View | None = self.view + self.parent: Item | BaseView | None = self.view def to_component_dict(self) -> dict[str, Any]: raise NotImplementedError @@ -192,7 +192,7 @@ def view(self) -> V | None: Returns ------- - Optional[:class:`View`] + Optional[:class:`BaseView`] The parent view of this item, or ``None`` if the item is not attached to any view. """ return self._view diff --git a/discord/ui/label.py b/discord/ui/label.py index 7288817a98..50d3461b5d 100644 --- a/discord/ui/label.py +++ b/discord/ui/label.py @@ -21,14 +21,14 @@ from ..interaction import Interaction from ..partial_emoji import PartialEmoji, _EmojiTag from ..types.components import Label as LabelPayload - from .view import View + from .modal import DesignerModal L = TypeVar("L", bound="Label") -V = TypeVar("V", bound="View", covariant=True) +M = TypeVar("M", bound="DesignerModal", covariant=True) -class Label(Item[V]): +class Label(Item[M]): """Represents a UI Label used in :class:`discord.ui.DesignerModal`. The items currently supported are as follows: @@ -298,12 +298,6 @@ def set_select( return self.set_item(select) - @Item.view.setter - def view(self, value): - self._view = value - self.item.parent = self - self.item._view = value - @property def type(self) -> ComponentType: return self._underlying.type diff --git a/discord/ui/media_gallery.py b/discord/ui/media_gallery.py index 246e34c282..6633ab5938 100644 --- a/discord/ui/media_gallery.py +++ b/discord/ui/media_gallery.py @@ -13,11 +13,11 @@ from typing_extensions import Self from ..types.components import MediaGalleryComponent as MediaGalleryComponentPayload - from .view import View + from .view import DesignerView M = TypeVar("M", bound="MediaGallery") -V = TypeVar("V", bound="View", covariant=True) +V = TypeVar("V", bound="DesignerView", covariant=True) class MediaGallery(Item[V]): diff --git a/discord/ui/section.py b/discord/ui/section.py index a991529fa3..def18726c6 100644 --- a/discord/ui/section.py +++ b/discord/ui/section.py @@ -18,11 +18,11 @@ from typing_extensions import Self from ..types.components import SectionComponent as SectionComponentPayload - from .view import View + from .view import DesignerView S = TypeVar("S", bound="Section") -V = TypeVar("V", bound="View", covariant=True) +V = TypeVar("V", bound="DesignerView", covariant=True) class Section(Item[V]): diff --git a/discord/ui/select.py b/discord/ui/select.py index b4789eff7a..881ee4e20f 100644 --- a/discord/ui/select.py +++ b/discord/ui/select.py @@ -67,7 +67,7 @@ from ..abc import GuildChannel, Snowflake from ..types.components import SelectMenu as SelectMenuPayload from ..types.interactions import ComponentInteractionData - from .view import View + from .view import BaseView ST = TypeVar("ST", bound=Snowflake | str, covariant=True, default=Any) else: @@ -77,7 +77,7 @@ ST = TypeVar("ST", bound="Snowflake | str", covariant=True) S = TypeVar("S", bound="Select") -V = TypeVar("V", bound="View", covariant=True) +V = TypeVar("V", bound="BaseView", covariant=True) class Select(Generic[V, ST], Item[V]): @@ -846,7 +846,7 @@ def select( """A decorator that attaches a select menu to a component. The function being decorated should have three parameters, ``self`` representing - the :class:`discord.ui.View`, the :class:`discord.ui.Select` being pressed and + the :class:`discord.ui.View`, :class:`discord.ui.ActionRow` or :class:`discord.ui.Section`, the :class:`discord.ui.Select` being pressed and the :class:`discord.Interaction` you receive. In order to get the selected items that the user has chosen within the callback diff --git a/discord/ui/separator.py b/discord/ui/separator.py index c9e213b438..c6d9bbd96b 100644 --- a/discord/ui/separator.py +++ b/discord/ui/separator.py @@ -11,11 +11,11 @@ if TYPE_CHECKING: from ..types.components import SeparatorComponent as SeparatorComponentPayload - from .view import View + from .view import DesignerView S = TypeVar("S", bound="Separator") -V = TypeVar("V", bound="View", covariant=True) +V = TypeVar("V", bound="DesignerView", covariant=True) class Separator(Item[V]): diff --git a/discord/ui/text_display.py b/discord/ui/text_display.py index 18acfee16e..b24ba103a0 100644 --- a/discord/ui/text_display.py +++ b/discord/ui/text_display.py @@ -11,14 +11,14 @@ if TYPE_CHECKING: from ..types.components import TextDisplayComponent as TextDisplayComponentPayload - from .view import View + from .core import ItemInterface T = TypeVar("T", bound="TextDisplay") -V = TypeVar("V", bound="View", covariant=True) +I = TypeVar("I", bound="ItemInterface", covariant=True) -class TextDisplay(Item[V]): +class TextDisplay(Item[I]): """Represents a UI text display. A message can have up to 4000 characters across all :class:`TextDisplay` objects combined. .. versionadded:: 2.7 diff --git a/discord/ui/thumbnail.py b/discord/ui/thumbnail.py index 28f92808e7..25db819234 100644 --- a/discord/ui/thumbnail.py +++ b/discord/ui/thumbnail.py @@ -11,11 +11,11 @@ if TYPE_CHECKING: from ..types.components import ThumbnailComponent as ThumbnailComponentPayload - from .view import View + from .view import DesignerView T = TypeVar("T", bound="Thumbnail") -V = TypeVar("V", bound="View", covariant=True) +V = TypeVar("V", bound="DesignerView", covariant=True) class Thumbnail(Item[V]): diff --git a/discord/ui/view.py b/discord/ui/view.py index bf3e2f5aa7..c7f2a5a130 100644 --- a/discord/ui/view.py +++ b/discord/ui/view.py @@ -67,7 +67,7 @@ from ..state import ConnectionState from ..types.components import Component as ComponentPayload -V = TypeVar("V", bound="View", covariant=True) +V = TypeVar("V", bound="BaseView", covariant=True) def _walk_all_components(components: list[Component]) -> Iterator[Component]: diff --git a/discord/webhook/async_.py b/discord/webhook/async_.py index aec9115d5f..58f99471fa 100644 --- a/discord/webhook/async_.py +++ b/discord/webhook/async_.py @@ -80,7 +80,7 @@ from ..types.message import Message as MessagePayload from ..types.webhook import FollowerWebhook as FollowerWebhookPayload from ..types.webhook import Webhook as WebhookPayload - from ..ui.view import View + from ..ui.view import BaseView MISSING = utils.MISSING @@ -640,7 +640,7 @@ def handle_message_parameters( attachments: list[Attachment] = MISSING, embed: Embed | None = MISSING, embeds: list[Embed] = MISSING, - view: View | None = MISSING, + view: BaseView | None = MISSING, poll: Poll | None = MISSING, applied_tags: list[Snowflake] = MISSING, allowed_mentions: AllowedMentions | None = MISSING, @@ -887,7 +887,7 @@ async def edit( file: File = MISSING, files: list[File] = MISSING, attachments: list[Attachment] = MISSING, - view: View | None = MISSING, + view: BaseView | None = MISSING, allowed_mentions: AllowedMentions | None = None, suppress: bool | None = MISSING, ) -> WebhookMessage: @@ -926,7 +926,7 @@ async def edit( allowed_mentions: :class:`AllowedMentions` Controls the mentions being processed in this message. See :meth:`.abc.Messageable.send` for more information. - view: Optional[:class:`~discord.ui.View`] + view: Optional[:class:`~discord.ui.BaseView`] The updated view to update this message with. If ``None`` is passed then the view is removed. @@ -1622,7 +1622,7 @@ async def send( embed: Embed = MISSING, embeds: list[Embed] = MISSING, allowed_mentions: AllowedMentions = MISSING, - view: View = MISSING, + view: BaseView = MISSING, poll: Poll = MISSING, thread: Snowflake = MISSING, thread_name: str | None = None, @@ -1645,7 +1645,7 @@ async def send( embed: Embed = MISSING, embeds: list[Embed] = MISSING, allowed_mentions: AllowedMentions = MISSING, - view: View = MISSING, + view: BaseView = MISSING, poll: Poll = MISSING, thread: Snowflake = MISSING, thread_name: str | None = None, @@ -1667,7 +1667,7 @@ async def send( embed: Embed = MISSING, embeds: list[Embed] = MISSING, allowed_mentions: AllowedMentions = MISSING, - view: View = MISSING, + view: BaseView = MISSING, poll: Poll = MISSING, thread: Snowflake = MISSING, thread_name: str | None = None, @@ -1728,7 +1728,7 @@ async def send( Controls the mentions being processed in this message. .. versionadded:: 1.4 - view: :class:`discord.ui.View` + view: :class:`discord.ui.BaseView` The view to send with the message. You can only send a view if this webhook is not partial and has state attached. A webhook has state attached if the webhook is managed by the @@ -1946,7 +1946,7 @@ async def edit_message( file: File = MISSING, files: list[File] = MISSING, attachments: list[Attachment] = MISSING, - view: View | None = MISSING, + view: BaseView | None = MISSING, allowed_mentions: AllowedMentions | None = None, thread: Snowflake | None = MISSING, suppress: bool = False, @@ -1989,7 +1989,7 @@ async def edit_message( allowed_mentions: :class:`AllowedMentions` Controls the mentions being processed in this message. See :meth:`.abc.Messageable.send` for more information. - view: Optional[:class:`~discord.ui.View`] + view: Optional[:class:`~discord.ui.BaseView`] The updated view to update this message with. If ``None`` is passed then the view is removed. The webhook must have state attached, similar to :meth:`send`. diff --git a/docs/api/ui_kit.rst b/docs/api/ui_kit.rst index 8714f59b85..41bd41dffa 100644 --- a/docs/api/ui_kit.rst +++ b/docs/api/ui_kit.rst @@ -33,16 +33,32 @@ Shortcut decorators Objects ------- +.. attributetable:: discord.ui.BaseView + +.. autoclass:: discord.ui.BaseView + :members: + .. attributetable:: discord.ui.View .. autoclass:: discord.ui.View :members: +.. attributetable:: discord.ui.DesignerView + +.. autoclass:: discord.ui.DesignerView + :members: + .. attributetable:: discord.ui.Item .. autoclass:: discord.ui.Item :members: +.. attributetable:: discord.ui.ActionRow + +.. autoclass:: discord.ui.ActionRow + :members: + :inherited-members: + .. attributetable:: discord.ui.Button .. autoclass:: discord.ui.Button @@ -118,12 +134,30 @@ Objects :members: :inherited-members: +.. attributetable:: discord.ui.BaseModal + +.. autoclass:: discord.ui.BaseModal + :members: + :inherited-members: + .. attributetable:: discord.ui.Modal .. autoclass:: discord.ui.Modal :members: :inherited-members: +.. attributetable:: discord.ui.DesignerModal + +.. autoclass:: discord.ui.DesignerModal + :members: + :inherited-members: + +.. attributetable:: discord.ui.Label + +.. autoclass:: discord.ui.Label + :members: + :inherited-members: + .. attributetable:: discord.ui.InputText .. autoclass:: discord.ui.InputText From 0acb1030543940620a126730ab46001372300cde Mon Sep 17 00:00:00 2001 From: UK <41271523+NeloBlivion@users.noreply.github.com> Date: Fri, 26 Sep 2025 11:33:35 -0400 Subject: [PATCH 66/99] update _component_to_item --- discord/ui/view.py | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/discord/ui/view.py b/discord/ui/view.py index c7f2a5a130..689dc0751f 100644 --- a/discord/ui/view.py +++ b/discord/ui/view.py @@ -127,14 +127,13 @@ def _component_to_item(component: Component) -> Item[V]: return Container.from_component(component) if isinstance(component, ActionRowComponent): - # Handle ActionRow.children manually, or design ui.ActionRow? + from .action_row import ActionRow - return component + return ActionRow.from_component(component) if isinstance(component, LabelComponent): - ret = _component_to_item(component.component) - ret.label = component.label - ret.description = component.description - return ret + from .label import Label + + return Label.from_component(component) return Item.from_component(component) From 43df60fad9cdc48b6437609274ab3edfb895450f Mon Sep 17 00:00:00 2001 From: UK <41271523+NeloBlivion@users.noreply.github.com> Date: Fri, 3 Oct 2025 11:43:10 -0400 Subject: [PATCH 67/99] fix to_component_dict --- discord/ui/action_row.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/discord/ui/action_row.py b/discord/ui/action_row.py index eeaa515702..996a256ea0 100644 --- a/discord/ui/action_row.py +++ b/discord/ui/action_row.py @@ -392,7 +392,7 @@ def walk_items(self) -> Iterator[Item]: yield from self.children def to_component_dict(self) -> ActionRowPayload: - self._set_components() + self._set_components(self.items) return self._underlying.to_dict() @classmethod From 0b58904c622913cc9551a131e2a26fc687a6af6d Mon Sep 17 00:00:00 2001 From: UK <41271523+NeloBlivion@users.noreply.github.com> Date: Fri, 3 Oct 2025 12:34:40 -0400 Subject: [PATCH 68/99] attempt splitting into ViewItem and ModalItem --- discord/ui/input_text.py | 3 +- discord/ui/item.py | 134 +++++++++++++++++++++++++++------------ discord/ui/label.py | 6 +- 3 files changed, 99 insertions(+), 44 deletions(-) diff --git a/discord/ui/input_text.py b/discord/ui/input_text.py index 9ccf73597d..da335b81f8 100644 --- a/discord/ui/input_text.py +++ b/discord/ui/input_text.py @@ -5,6 +5,7 @@ from ..components import InputText as InputTextComponent from ..enums import ComponentType, InputTextStyle +from .item import ModalItem __all__ = ("InputText", "TextInput") @@ -13,7 +14,7 @@ from ..types.components import InputText as InputTextComponentPayload -class InputText: +class InputText(ModalItem): """Represents a UI text input field. .. versionadded:: 2.0 diff --git a/discord/ui/item.py b/discord/ui/item.py index 4766b5e0dc..166170266e 100644 --- a/discord/ui/item.py +++ b/discord/ui/item.py @@ -29,19 +29,23 @@ from ..interactions import Interaction -__all__ = ("Item",) +__all__ = ("Item", "ViewItem", "ModalItem", ) 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("IF", 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[V]): +class Item(Generic[T]): """Represents the base UI item that all UI components inherit from. The following are the original items supported in :class:`discord.ui.View`: @@ -65,21 +69,12 @@ class Item(Generic[V]): Added V2 Components. """ - __item_repr_attributes__: tuple[str, ...] = ("row",) + __item_repr_attributes__: tuple[str, ...] = ("id",) def __init__(self): - self._view: V | None = None - self._row: int | None = None - self._rendered_row: int | None = None self._underlying: Component | None = None - # This works mostly well but there is a gotcha with - # the interaction with from_component, since that technically provides - # a custom_id most dispatchable items would get this set to True even though - # it might not be provided by the library user. However, this edge case doesn't - # actually affect the intended purpose of this check because from_component is - # only called upon edit and we're mainly interested during initial creation time. self._provided_custom_id: bool = False - self.parent: Item | BaseView | None = self.view + self.parent: Item | ItemInterface | None = None def to_component_dict(self) -> dict[str, Any]: raise NotImplementedError @@ -90,9 +85,6 @@ def refresh_component(self, component: Component) -> None: def refresh_state(self, interaction: Interaction) -> None: return None - def refresh_from_modal(self, interaction: Interaction, data: dict) -> None: - return None - @classmethod def from_component(cls: type[I], component: Component) -> I: return cls() @@ -110,9 +102,6 @@ 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 "" @@ -122,6 +111,55 @@ def __repr__(self) -> str: ) 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._row: int | None = None + self._rendered_row: int | None = None + self.parent: Item | BaseView | None = self.view + @property def row(self) -> int | None: """Gets or sets the row position of this item within its parent view. @@ -164,30 +202,11 @@ def width(self) -> int: """ return 1 - @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 View 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 - @property def view(self) -> V | None: """Gets the parent view associated with this item. - The view refers to the container that holds this item. This is typically set + The view refers to the structure that holds this item. This is typically set automatically when the item is added to a view. Returns @@ -213,3 +232,40 @@ async def callback(self, interaction: Interaction): 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` + + .. versionadded:: 2.7 + """ + + def __init__(self): + super().__init__() + self._modal: V | None = None + self.parent: Item | BaseModal | None = self.modal + + def refresh_from_modal(self, interaction: Interaction, data: dict) -> None: + return None + + @property + def modal(self) -> V | 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 diff --git a/discord/ui/label.py b/discord/ui/label.py index 50d3461b5d..052921e232 100644 --- a/discord/ui/label.py +++ b/discord/ui/label.py @@ -9,7 +9,7 @@ from ..utils import find, get from .button import Button from .input_text import InputText -from .item import Item, ItemCallbackType +from .item import ModalItem, ItemCallbackType from .select import Select __all__ = ("Label",) @@ -28,7 +28,7 @@ M = TypeVar("M", bound="DesignerModal", covariant=True) -class Label(Item[M]): +class Label(ModalItem[M]): """Represents a UI Label used in :class:`discord.ui.DesignerModal`. The items currently supported are as follows: @@ -351,5 +351,3 @@ def from_component(cls: type[L], component: LabelComponent) -> L: label=component.label, description=component.description, ) - - callback = None From 3a8bb10a5460e8a917c446eae6baa8ffc26c8cfd Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Fri, 3 Oct 2025 16:35:08 +0000 Subject: [PATCH 69/99] style(pre-commit): auto fixes from pre-commit.com hooks --- discord/ui/item.py | 8 +++++++- discord/ui/label.py | 2 +- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/discord/ui/item.py b/discord/ui/item.py index 166170266e..27836090b0 100644 --- a/discord/ui/item.py +++ b/discord/ui/item.py @@ -29,7 +29,11 @@ from ..interactions import Interaction -__all__ = ("Item", "ViewItem", "ModalItem", ) +__all__ = ( + "Item", + "ViewItem", + "ModalItem", +) if TYPE_CHECKING: from ..components import Component @@ -130,6 +134,7 @@ def id(self, value) -> None: return self._underlying.id = value + class ViewItem(Item[V]): """Represents an item used in Views. @@ -233,6 +238,7 @@ async def callback(self, interaction: Interaction): The interaction that triggered this UI item. """ + class ModalItem(Item[M]): """Represents an item used in Modals. diff --git a/discord/ui/label.py b/discord/ui/label.py index 052921e232..600d5ecf75 100644 --- a/discord/ui/label.py +++ b/discord/ui/label.py @@ -9,7 +9,7 @@ from ..utils import find, get from .button import Button from .input_text import InputText -from .item import ModalItem, ItemCallbackType +from .item import ItemCallbackType, ModalItem from .select import Select __all__ = ("Label",) From 64f3c4fc73ee2a5b4f00381f9bc256969ff03dc1 Mon Sep 17 00:00:00 2001 From: UK <41271523+NeloBlivion@users.noreply.github.com> Date: Fri, 3 Oct 2025 12:44:59 -0400 Subject: [PATCH 70/99] more --- discord/ui/action_row.py | 42 ++++++++++++++++++++-------------------- discord/ui/label.py | 32 +++++++++++++++++------------- 2 files changed, 40 insertions(+), 34 deletions(-) diff --git a/discord/ui/action_row.py b/discord/ui/action_row.py index 996a256ea0..5984e60d50 100644 --- a/discord/ui/action_row.py +++ b/discord/ui/action_row.py @@ -10,7 +10,7 @@ from ..utils import find, get from .button import Button from .file import File -from .item import Item, ItemCallbackType +from .item import ViewItem, ItemCallbackType from .select import Select __all__ = ("ActionRow",) @@ -28,7 +28,7 @@ V = TypeVar("V", bound="DesignerView", covariant=True) -class ActionRow(Item[V]): +class ActionRow(ViewItem[V]): """Represents a UI Action Row used in :class:`discord.ui.DesignerView`. The items supported are as follows: @@ -40,7 +40,7 @@ class ActionRow(Item[V]): Parameters ---------- - *items: :class:`Item` + *items: :class:`ViewItem` The initial items in this action row. id: Optional[:class:`int`] The action's ID. @@ -64,12 +64,12 @@ def __init_subclass__(cls) -> None: def __init__( self, - *items: Item, + *items: ViewItem, id: int | None = None, ): super().__init__() - self.children: list[Item] = [] + self.children: list[ViewItem] = [] self._underlying = ActionRowComponent._raw_construct( type=ComponentType.action_row, @@ -78,7 +78,7 @@ def __init__( ) for func in self.__row_children_items__: - item: Item = func.__discord_ui_model_type__( + item: ViewItem = func.__discord_ui_model_type__( **func.__discord_ui_model_kwargs__ ) item.callback = partial(func, self, item) @@ -87,26 +87,26 @@ def __init__( for i in items: self.add_item(i) - def _add_component_from_item(self, item: Item): + def _add_component_from_item(self, item: ViewItem): self._underlying.children.append(item._underlying) - def _set_components(self, items: list[Item]): + def _set_components(self, items: list[ViewItem]): self._underlying.children.clear() for item in items: self._add_component_from_item(item) - def add_item(self, item: Item) -> Self: + def add_item(self, item: ViewItem) -> Self: """Adds an item to the action row. Parameters ---------- - item: :class:`Item` + item: :class:`ViewItem` The item to add to the action row. Raises ------ TypeError - An :class:`Item` was not passed. + A :class:`ViewItem` was not passed. """ if not isinstance(item, (Select, Button)): @@ -123,12 +123,12 @@ def add_item(self, item: Item) -> Self: self._add_component_from_item(item) return self - def remove_item(self, item: Item | str | int) -> 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:`Item`, :class:`int`, :class:`str`] + item: Union[:class:`ViewItem`, :class:`int`, :class:`str`] The item, ``id``, or item ``custom_id`` to remove from the action row. """ @@ -140,7 +140,7 @@ def remove_item(self, item: Item | str | int) -> Self: pass return self - def get_item(self, id: str | int) -> Item | None: + 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``. @@ -151,7 +151,7 @@ def get_item(self, id: str | int) -> Item | None: Returns ------- - Optional[:class:`Item`] + Optional[:class:`ViewItem`] The item with the matching ``id`` or ``custom_id`` if it exists. """ if not id: @@ -327,7 +327,7 @@ def add_select( return self.add_item(select) - @Item.view.setter + @ViewItem.view.setter def view(self, value): self._view = value for item in self.children: @@ -352,13 +352,13 @@ def refresh_component(self, component: ActionRowComponent) -> None: x.refresh_component(y) i += 1 - def disable_all_items(self, *, exclusions: list[Item] | None = None) -> Self: + def disable_all_items(self, *, exclusions: list[ViewItem] | None = None) -> Self: """ Disables all items in the row. Parameters ---------- - exclusions: Optional[List[:class:`Item`]] + exclusions: Optional[List[:class:`ViewItem`]] A list of items in `self.children` to not disable. """ for item in self.walk_items(): @@ -366,13 +366,13 @@ def disable_all_items(self, *, exclusions: list[Item] | None = None) -> Self: item.disabled = True return self - def enable_all_items(self, *, exclusions: list[Item] | None = None) -> Self: + def enable_all_items(self, *, exclusions: list[ViewItem] | None = None) -> Self: """ Enables all items in the row. Parameters ---------- - exclusions: Optional[List[:class:`Item`]] + exclusions: Optional[List[:class:`ViewItem`]] A list of items in `self.children` to not enable. """ for item in self.walk_items(): @@ -388,7 +388,7 @@ def width(self): t += 1 if item._underlying.type is ComponentType.button else 5 return t - def walk_items(self) -> Iterator[Item]: + def walk_items(self) -> Iterator[ViewItem]: yield from self.children def to_component_dict(self) -> ActionRowPayload: diff --git a/discord/ui/label.py b/discord/ui/label.py index 600d5ecf75..a983efe534 100644 --- a/discord/ui/label.py +++ b/discord/ui/label.py @@ -40,8 +40,8 @@ class Label(ModalItem[M]): Parameters ---------- - item: :class:`Item` - The initial item in this label. + 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`] @@ -60,14 +60,14 @@ class Label(ModalItem[M]): def __init__( self, label: str, - item: Item = None, + item: ModalItem = None, *, description: str | None = None, id: int | None = None, ): super().__init__() - self.item: Item = None + self.item: ModalItem = None self._underlying = LabelComponent._raw_construct( type=ComponentType.label, @@ -80,26 +80,32 @@ def __init__( if item: self.set_item(item) - def _set_component_from_item(self, 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._underlying - def set_item(self, item: Item) -> Self: + def set_item(self, item: ModalItem) -> Self: """Set this label's item. Parameters ---------- - item: Union[:class:`Item`, :class:`InputText`] + item: Union[:class:`ModalItem`, :class:`InputText`] The item to set. Currently only supports :class:`~discord.ui.Select` and :class:`~discord.ui.InputText`. Raises ------ TypeError - An :class:`Item` was not passed. + A :class:`ModalItem` was not passed. """ - if not isinstance(item, (Item, InputText)): - raise TypeError(f"expected Item not {item.__class__!r}") + 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.view: @@ -110,7 +116,7 @@ def set_item(self, item: Item) -> Self: self._set_component_from_item(item) return self - def get_item(self, id: str | int) -> Item | None: + 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``. @@ -121,7 +127,7 @@ def get_item(self, id: str | int) -> Item | None: Returns ------- - Optional[:class:`Item`] + Optional[:class:`ModalItem`] The item if its ``id`` or ``custom_id`` matches. """ if not id: @@ -330,7 +336,7 @@ def refresh_component(self, component: LabelComponent) -> None: self._underlying = component self.item.refresh_component(component.component) - def walk_items(self) -> Iterator[Item]: + def walk_items(self) -> Iterator[ModalItem]: yield from [self.item] def to_component_dict(self) -> LabelPayload: From 45a5c44aeaa5a4daa7740a94576af34fdf0e2c1b Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Fri, 3 Oct 2025 16:45:25 +0000 Subject: [PATCH 71/99] style(pre-commit): auto fixes from pre-commit.com hooks --- discord/ui/action_row.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/discord/ui/action_row.py b/discord/ui/action_row.py index 5984e60d50..e85276a284 100644 --- a/discord/ui/action_row.py +++ b/discord/ui/action_row.py @@ -10,7 +10,7 @@ from ..utils import find, get from .button import Button from .file import File -from .item import ViewItem, ItemCallbackType +from .item import ItemCallbackType, ViewItem from .select import Select __all__ = ("ActionRow",) From 4ee4b7028984131b6635626aa03c43cb4beb022d Mon Sep 17 00:00:00 2001 From: UK <41271523+NeloBlivion@users.noreply.github.com> Date: Fri, 3 Oct 2025 13:18:07 -0400 Subject: [PATCH 72/99] guys don't you love typing changes --- discord/client.py | 13 +++--- discord/ui/button.py | 4 +- discord/ui/container.py | 56 +++++++++++------------ discord/ui/file.py | 4 +- discord/ui/item.py | 8 ++-- discord/ui/media_gallery.py | 8 +--- discord/ui/modal.py | 40 ++++++++--------- discord/ui/section.py | 60 ++++++++++++------------- discord/ui/select.py | 5 ++- discord/ui/separator.py | 4 +- discord/ui/text_display.py | 7 +-- discord/ui/thumbnail.py | 4 +- discord/ui/view.py | 90 ++++++++++++++++++------------------- docs/api/ui_kit.rst | 14 ++++++ 14 files changed, 165 insertions(+), 152 deletions(-) diff --git a/discord/client.py b/discord/client.py index 8011ca0b92..5a5c654200 100644 --- a/discord/client.py +++ b/discord/client.py @@ -74,7 +74,8 @@ from .message import Message from .poll import Poll from .soundboard import SoundboardSound - from .ui.item import Item + from .ui.item import Item, ViewItem + from .ui.modal import BaseModal from .voice_client import VoiceProtocol __all__ = ("Client",) @@ -546,7 +547,7 @@ async def on_error(self, event_method: str, *args: Any, **kwargs: Any) -> None: traceback.print_exc() async def on_view_error( - self, error: Exception, item: Item, interaction: Interaction + self, error: Exception, item: ViewItem, interaction: Interaction ) -> None: """|coro| @@ -558,7 +559,7 @@ async def on_view_error( ---------- error: :class:`Exception` The exception that was raised. - item: :class:`Item` + item: :class:`ViewItem` The item that the user interacted with. interaction: :class:`Interaction` The interaction that was received. @@ -572,7 +573,7 @@ async def on_view_error( error.__class__, error, error.__traceback__, file=sys.stderr ) - async def on_modal_error(self, error: Exception, interaction: Interaction) -> None: + async def on_modal_error(self, error: Exception, modal: BaseModal, interaction: Interaction) -> None: """|coro| The default modal error handler provided by the client. @@ -584,11 +585,13 @@ async def on_modal_error(self, error: Exception, interaction: Interaction) -> No ---------- error: :class:`Exception` The exception that was raised. + modal: :class:`BaseModal` + The modal that failed the dispatch. interaction: :class:`Interaction` The interaction that was received. """ - print(f"Ignoring exception in modal {interaction.modal}:", file=sys.stderr) + print(f"Ignoring exception in modal {modal}:", file=sys.stderr) traceback.print_exception( error.__class__, error, error.__traceback__, file=sys.stderr ) diff --git a/discord/ui/button.py b/discord/ui/button.py index 320dca2005..e4e780d77a 100644 --- a/discord/ui/button.py +++ b/discord/ui/button.py @@ -32,7 +32,7 @@ from ..components import Button as ButtonComponent from ..enums import ButtonStyle, ComponentType from ..partial_emoji import PartialEmoji, _EmojiTag -from .item import Item, ItemCallbackType +from .item import ViewItem, ItemCallbackType __all__ = ( "Button", @@ -47,7 +47,7 @@ V = TypeVar("V", bound="BaseView", covariant=True) -class Button(Item[V]): +class Button(ViewItem[V]): """Represents a UI button. .. versionadded:: 2.0 diff --git a/discord/ui/container.py b/discord/ui/container.py index 33e9798420..cb7fae95cc 100644 --- a/discord/ui/container.py +++ b/discord/ui/container.py @@ -11,7 +11,7 @@ from .action_row import ActionRow from .button import Button from .file import File -from .item import Item, ItemCallbackType +from .item import ViewItem, ItemCallbackType from .media_gallery import MediaGallery from .section import Section from .select import Select @@ -32,7 +32,7 @@ V = TypeVar("V", bound="DesignerView", covariant=True) -class Container(Item[V]): +class Container(ViewItem[V]): """Represents a UI Container. The current items supported are as follows: @@ -48,7 +48,7 @@ class Container(Item[V]): Parameters ---------- - *items: :class:`Item` + *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. @@ -75,7 +75,7 @@ def __init_subclass__(cls) -> None: def __init__( self, - *items: Item, + *items: ViewItem, colour: int | Colour | None = None, color: int | Colour | None = None, spoiler: bool = False, @@ -83,7 +83,7 @@ def __init__( ): super().__init__() - self.items: list[Item] = [] + self.items: list[ViewItem] = [] self._underlying = ContainerComponent._raw_construct( type=ComponentType.container, @@ -96,30 +96,30 @@ def __init__( for i in items: self.add_item(i) - def _add_component_from_item(self, item: Item): + def _add_component_from_item(self, item: ViewItem): self._underlying.components.append(item._underlying) - def _set_components(self, items: list[Item]): + def _set_components(self, items: list[ViewItem]): self._underlying.components.clear() for item in items: self._add_component_from_item(item) - def add_item(self, item: Item) -> Self: + def add_item(self, item: ViewItem) -> Self: """Adds an item to the container. Parameters ---------- - item: :class:`Item` + item: :class:`ViewItem` The item to add to the container. Raises ------ TypeError - An :class:`Item` was not passed. + A :class:`ViewItem` was not passed. """ - if not isinstance(item, Item): - raise TypeError(f"expected Item not {item.__class__!r}") + if not isinstance(item, ViewItem): + raise TypeError(f"expected ViewItem not {item.__class__!r}") if isinstance(item, (Button, Select)): raise TypeError( @@ -135,12 +135,12 @@ def add_item(self, item: Item) -> Self: self._add_component_from_item(item) return self - def remove_item(self, item: Item | str | int) -> 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:`Item`, :class:`int`, :class:`str`] + item: Union[:class:`ViewItem`, :class:`int`, :class:`str`] The item, ``id``, or item ``custom_id`` to remove from the container. """ @@ -155,7 +155,7 @@ def remove_item(self, item: Item | str | int) -> Self: pass return self - def get_item(self, id: str | int) -> Item | None: + 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. @@ -167,7 +167,7 @@ def get_item(self, id: str | int) -> Item | None: Returns ------- - Optional[:class:`Item`] + Optional[:class:`ViewItem`] The item with the matching ``id`` or ``custom_id`` if it exists. """ if not id: @@ -183,7 +183,7 @@ def get_item(self, id: str | int) -> Item | None: def add_row( self, - *items: Item, + *items: ViewItem, id: int | None = None, ) -> Self: """Adds an :class:`ActionRow` to the container. @@ -204,8 +204,8 @@ def add_row( def add_section( self, - *items: Item, - accessory: Item, + *items: ViewItem, + accessory: ViewItem, id: int | None = None, ) -> Self: """Adds a :class:`Section` to the container. @@ -215,10 +215,10 @@ def add_section( Parameters ---------- - *items: :class:`Item` + *items: :class:`ViewItem` The items contained in this section, up to 3. Currently only supports :class:`~discord.ui.TextDisplay`. - accessory: Optional[:class:`Item`] + 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`] @@ -246,7 +246,7 @@ def add_text(self, content: str, id: int | None = None) -> Self: def add_gallery( self, - *items: Item, + *items: ViewItem, id: int | None = None, ) -> Self: """Adds a :class:`MediaGallery` to the container. @@ -338,7 +338,7 @@ def colour(self, value: int | Colour | None): # type: ignore color = colour - @Item.view.setter + @ViewItem.view.setter def view(self, value): self._view = value for item in self.items: @@ -365,13 +365,13 @@ def refresh_component(self, component: ContainerComponent) -> None: x.refresh_component(y) i += 1 - def disable_all_items(self, *, exclusions: list[Item] | None = None) -> Self: + 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:`Item`]] + exclusions: Optional[List[:class:`ViewItem`]] A list of items in `self.items` to not disable from the view. """ for item in self.walk_items(): @@ -381,13 +381,13 @@ def disable_all_items(self, *, exclusions: list[Item] | None = None) -> Self: item.disabled = True return self - def enable_all_items(self, *, exclusions: list[Item] | None = None) -> 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:`Item`]] + exclusions: Optional[List[:class:`ViewItem`]] A list of items in `self.items` to not enable from the view. """ for item in self.walk_items(): @@ -397,7 +397,7 @@ def enable_all_items(self, *, exclusions: list[Item] | None = None) -> Self: item.disabled = False return self - def walk_items(self) -> Iterator[Item]: + def walk_items(self) -> Iterator[ViewItem]: for item in self.items: if hasattr(item, "walk_items"): yield from item.walk_items() diff --git a/discord/ui/file.py b/discord/ui/file.py index aadde37bcd..141a539310 100644 --- a/discord/ui/file.py +++ b/discord/ui/file.py @@ -5,7 +5,7 @@ from ..components import FileComponent, UnfurledMediaItem, _component_factory from ..enums import ComponentType -from .item import Item +from .item import ViewItem __all__ = ("File",) @@ -18,7 +18,7 @@ V = TypeVar("V", bound="DesignerView", covariant=True) -class File(Item[V]): +class File(ViewItem[V]): """Represents a UI File. .. note:: diff --git a/discord/ui/item.py b/discord/ui/item.py index 27836090b0..2062e600f1 100644 --- a/discord/ui/item.py +++ b/discord/ui/item.py @@ -163,7 +163,7 @@ def __init__(self): self._view: V | None = None self._row: int | None = None self._rendered_row: int | None = None - self.parent: Item | BaseView | None = self.view + self.parent: ViewItem | BaseView | None = self.view @property def row(self) -> int | None: @@ -254,14 +254,14 @@ class ModalItem(Item[M]): def __init__(self): super().__init__() - self._modal: V | None = None - self.parent: Item | BaseModal | None = self.modal + 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) -> V | None: + 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. diff --git a/discord/ui/media_gallery.py b/discord/ui/media_gallery.py index 6633ab5938..0d4ef03db5 100644 --- a/discord/ui/media_gallery.py +++ b/discord/ui/media_gallery.py @@ -5,7 +5,7 @@ from ..components import MediaGallery as MediaGalleryComponent from ..components import MediaGalleryItem from ..enums import ComponentType -from .item import Item +from .item import ViewItem __all__ = ("MediaGallery",) @@ -20,7 +20,7 @@ V = TypeVar("V", bound="DesignerView", covariant=True) -class MediaGallery(Item[V]): +class MediaGallery(ViewItem[V]): """Represents a UI Media Gallery. Galleries may contain up to 10 :class:`MediaGalleryItem` objects. .. versionadded:: 2.7 @@ -105,10 +105,6 @@ def add_item( return self.append_item(item) - @Item.view.setter - def view(self, value): - self._view = value - @property def type(self) -> ComponentType: return self._underlying.type diff --git a/discord/ui/modal.py b/discord/ui/modal.py index 8d9cd2bd3b..281a7a4429 100644 --- a/discord/ui/modal.py +++ b/discord/ui/modal.py @@ -12,7 +12,7 @@ from ..utils import find from .core import ItemInterface from .input_text import InputText -from .item import Item +from .item import ModalItem from .label import Label from .select import Select from .text_display import TextDisplay @@ -34,8 +34,6 @@ M = TypeVar("M", bound="Modal", covariant=True) -ModalItem = Union[Item[M]] - class BaseModal(ItemInterface): """Represents a UI modal. @@ -46,7 +44,7 @@ class BaseModal(ItemInterface): Parameters ---------- - children: Union[:class:`Item`] + children: :class:`ModalItem` The initial items that are displayed in the modal. title: :class:`str` The title of the modal. @@ -131,9 +129,9 @@ def children(self) -> list[ModalItem]: @children.setter def children(self, value: list[ModalItem]): for item in value: - if not isinstance(item, Item): + if not isinstance(item, ModalItem): raise TypeError( - "all BaseModal children must be Item, not" + "all BaseModal children must be ModalItem, not" f" {item.__class__.__name__}" ) self._children = value @@ -171,21 +169,15 @@ def add_item(self, item: ModalItem) -> Self: Parameters ---------- - item: Union[class:`InputText`, :class:`Item`] + item: Union[class:`InputText`, :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, - ( - Item, - InputText, - ), - ): - raise TypeError(f"expected Item, not {item.__class__!r}") + if not isinstance(item, ModalItem): + raise TypeError(f"expected ModalItem, not {item.__class__!r}") self._children.append(item) return self @@ -195,7 +187,7 @@ def remove_item(self, item: ModalItem) -> Self: Parameters ---------- - item: Union[class:`InputText`, :class:`Item`] + item: :class:`ModalItem` The item to remove from the modal. """ try: @@ -235,10 +227,12 @@ async def on_error(self, error: Exception, interaction: Interaction) -> None: ---------- error: :class:`Exception` The exception that was raised. + modal: :class:`BaseModal` + The modal that failed the dispatch. interaction: :class:`~discord.Interaction` The interaction that led to the failure. """ - interaction.client.dispatch("modal_error", error, interaction) + interaction.client.dispatch("modal_error", error, self, interaction) async def on_timeout(self) -> None: """|coro| @@ -372,7 +366,7 @@ class DesignerModal(BaseModal): Parameters ---------- - children: Union[:class:`Item`] + children: Union[:class:`ModalItem`] The initial items that are displayed in the modal.. title: :class:`str` The title of the modal. @@ -401,11 +395,15 @@ def children(self) -> list[ModalItem]: @children.setter def children(self, value: list[ModalItem]): for item in value: - if not isinstance(item, Item): + if not isinstance(item, ModalItem): raise TypeError( - "all DesignerModal children must be Item, not" + "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: @@ -413,7 +411,7 @@ def add_item(self, item: ModalItem) -> Self: Parameters ---------- - item: Union[:class:`Item`] + item: Union[:class:`ModalItem`] The item to add to the modal """ diff --git a/discord/ui/section.py b/discord/ui/section.py index def18726c6..d730a31111 100644 --- a/discord/ui/section.py +++ b/discord/ui/section.py @@ -8,7 +8,7 @@ from ..enums import ComponentType from ..utils import find, get from .button import Button -from .item import Item, ItemCallbackType +from .item import ViewItem, ItemCallbackType from .text_display import TextDisplay from .thumbnail import Thumbnail @@ -25,18 +25,18 @@ V = TypeVar("V", bound="DesignerView", covariant=True) -class Section(Item[V]): +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:`Item` + *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:`Item`] + 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. @@ -61,11 +61,11 @@ def __init_subclass__(cls) -> None: cls.__section_accessory_item__ = accessory - def __init__(self, *items: Item, accessory: Item = None, id: int | None = None): + def __init__(self, *items: ViewItem, accessory: ViewItem = None, id: int | None = None): super().__init__() - self.items: list[Item] = [] - self.accessory: Item | None = None + self.items: list[ViewItem] = [] + self.accessory: ViewItem | None = None self._underlying = SectionComponent._raw_construct( type=ComponentType.section, @@ -74,7 +74,7 @@ def __init__(self, *items: Item, accessory: Item = None, id: int | None = None): accessory=None, ) for func in self.__section_accessory_item__: - item: Item = func.__discord_ui_model_type__( + item: ViewItem = func.__discord_ui_model_type__( **func.__discord_ui_model_kwargs__ ) item.callback = partial(func, self, item) @@ -85,26 +85,26 @@ def __init__(self, *items: Item, accessory: Item = None, id: int | None = None): for i in items: self.add_item(i) - def _add_component_from_item(self, item: Item): + def _add_component_from_item(self, item: ViewItem): self._underlying.components.append(item._underlying) - def _set_components(self, items: list[Item]): + def _set_components(self, items: list[ViewItem]): self._underlying.components.clear() for item in items: self._add_component_from_item(item) - def add_item(self, item: Item) -> Self: + def add_item(self, item: ViewItem) -> Self: """Adds an item to the section. Parameters ---------- - item: :class:`Item` + item: :class:`ViewItem` The item to add to the section. Raises ------ TypeError - An :class:`Item` was not passed. + An :class:`ViewItem` was not passed. ValueError Maximum number of items has been exceeded (3). """ @@ -112,21 +112,21 @@ def add_item(self, item: Item) -> Self: if len(self.items) >= 3: raise ValueError("maximum number of children exceeded") - if not isinstance(item, Item): - raise TypeError(f"expected Item not {item.__class__!r}") + 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: Item | str | int) -> 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:`Item`, :class:`int`, :class:`str`] + item: Union[:class:`ViewItem`, :class:`int`, :class:`str`] The item, item ``id``, or item ``custom_id`` to remove from the section. """ @@ -138,7 +138,7 @@ def remove_item(self, item: Item | str | int) -> Self: pass return self - def get_item(self, id: int | str) -> Item | None: + 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``. @@ -149,7 +149,7 @@ def get_item(self, id: int | str) -> Item | None: Returns ------- - Optional[:class:`Item`] + Optional[:class:`ViewItem`] The item with the matching ``id`` if it exists. """ if not id: @@ -183,23 +183,23 @@ def add_text(self, content: str, *, id: int | None = None) -> Self: return self.add_item(text) - def set_accessory(self, item: Item) -> Self: + def set_accessory(self, item: ViewItem) -> Self: """Set an item as the section's :attr:`accessory`. Parameters ---------- - item: :class:`Item` + 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:`Item` was not passed. + An :class:`ViewItem` was not passed. """ - if not isinstance(item, Item): - raise TypeError(f"expected Item not {item.__class__!r}") + if not isinstance(item, ViewItem): + raise TypeError(f"expected ViewItem not {item.__class__!r}") if self.view: item._view = self.view item.parent = self @@ -234,7 +234,7 @@ def set_thumbnail( return self.set_accessory(thumbnail) - @Item.view.setter + @ViewItem.view.setter def view(self, value): self._view = value for item in self.walk_items(): @@ -266,14 +266,14 @@ def refresh_component(self, component: SectionComponent) -> None: if self.accessory and component.accessory: self.accessory.refresh_component(component.accessory) - def disable_all_items(self, *, exclusions: list[Item] | None = None) -> Self: + 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:`Item`]] + exclusions: Optional[List[:class:`ViewItem`]] A list of items in `self.items` to not disable from the view. """ for item in self.walk_items(): @@ -283,14 +283,14 @@ def disable_all_items(self, *, exclusions: list[Item] | None = None) -> Self: item.disabled = True return self - def enable_all_items(self, *, exclusions: list[Item] | None = None) -> 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:`Item`]] + exclusions: Optional[List[:class:`ViewItem`]] A list of items in `self.items` to not enable from the view. """ for item in self.walk_items(): @@ -300,7 +300,7 @@ def enable_all_items(self, *, exclusions: list[Item] | None = None) -> Self: item.disabled = False return self - def walk_items(self) -> Iterator[Item]: + def walk_items(self) -> Iterator[ViewItem]: r = self.items if self.accessory: yield from r + [self.accessory] diff --git a/discord/ui/select.py b/discord/ui/select.py index 881ee4e20f..2b562b63a0 100644 --- a/discord/ui/select.py +++ b/discord/ui/select.py @@ -44,7 +44,7 @@ from ..threads import Thread from ..user import User from ..utils import MISSING -from .item import Item, ItemCallbackType +from .item import ViewItem, ItemCallbackType __all__ = ( "Select", @@ -78,9 +78,10 @@ S = TypeVar("S", bound="Select") V = TypeVar("V", bound="BaseView", covariant=True) +M = TypeVar("M", bound="DesignerModal", covariant=True) -class Select(Generic[V, ST], Item[V]): +class Select(Generic[V, ST], ViewItem[V], ModalItem[M]): """Represents a UI select menu. This is usually represented as a drop down menu. diff --git a/discord/ui/separator.py b/discord/ui/separator.py index c6d9bbd96b..771dd7f123 100644 --- a/discord/ui/separator.py +++ b/discord/ui/separator.py @@ -5,7 +5,7 @@ from ..components import Separator as SeparatorComponent from ..components import _component_factory from ..enums import ComponentType, SeparatorSpacingSize -from .item import Item +from .item import ViewItem __all__ = ("Separator",) @@ -18,7 +18,7 @@ V = TypeVar("V", bound="DesignerView", covariant=True) -class Separator(Item[V]): +class Separator(ViewItem[V]): """Represents a UI Separator. .. versionadded:: 2.7 diff --git a/discord/ui/text_display.py b/discord/ui/text_display.py index b24ba103a0..e44ecc8bb3 100644 --- a/discord/ui/text_display.py +++ b/discord/ui/text_display.py @@ -5,7 +5,7 @@ from ..components import TextDisplay as TextDisplayComponent from ..components import _component_factory from ..enums import ComponentType -from .item import Item +from .item import ViewItem, ModalItem __all__ = ("TextDisplay",) @@ -15,10 +15,11 @@ T = TypeVar("T", bound="TextDisplay") -I = TypeVar("I", bound="ItemInterface", covariant=True) +V = TypeVar("V", bound="DesignerView", covariant=True) +M = TypeVar("M", bound="DesignerModal", covariant=True) -class TextDisplay(Item[I]): +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 diff --git a/discord/ui/thumbnail.py b/discord/ui/thumbnail.py index 25db819234..4f9a947e64 100644 --- a/discord/ui/thumbnail.py +++ b/discord/ui/thumbnail.py @@ -5,7 +5,7 @@ from ..components import Thumbnail as ThumbnailComponent from ..components import UnfurledMediaItem, _component_factory from ..enums import ComponentType -from .item import Item +from .item import ViewItem __all__ = ("Thumbnail",) @@ -18,7 +18,7 @@ V = TypeVar("V", bound="DesignerView", covariant=True) -class Thumbnail(Item[V]): +class Thumbnail(ViewItem[V]): """Represents a UI Thumbnail. .. versionadded:: 2.7 diff --git a/discord/ui/view.py b/discord/ui/view.py index 689dc0751f..7c72f827d0 100644 --- a/discord/ui/view.py +++ b/discord/ui/view.py @@ -50,7 +50,7 @@ from ..components import _component_factory from ..utils import find from .core import ItemInterface -from .item import Item, ItemCallbackType +from .item import ViewItem, ItemCallbackType __all__ = ( "BaseView", @@ -88,7 +88,7 @@ def _walk_all_components_v2(components: list[Component]) -> Iterator[Component]: yield item -def _component_to_item(component: Component) -> Item[V]: +def _component_to_item(component: Component) -> ViewItem[V]: if isinstance(component, ButtonComponent): from .button import Button @@ -134,13 +134,13 @@ def _component_to_item(component: Component) -> Item[V]: from .label import Label return Label.from_component(component) - return Item.from_component(component) + return ViewItem.from_component(component) class _ViewWeights: __slots__ = ("weights",) - def __init__(self, children: list[Item[V]]): + def __init__(self, children: list[ViewItem[V]]): self.weights: list[int] = [0, 0, 0, 0, 0] key = lambda i: sys.maxsize if i.row is None else i.row @@ -149,7 +149,7 @@ def __init__(self, children: list[Item[V]]): for item in group: self.add_item(item) - def find_open_space(self, item: Item[V]) -> int: + def find_open_space(self, item: ViewItem[V]) -> int: for index, weight in enumerate(self.weights): # check if open space AND (next row has no items OR this is the last row) if (weight + item.width <= 5) and ( @@ -160,7 +160,7 @@ def find_open_space(self, item: Item[V]) -> int: raise ValueError("could not find open space for item") - def add_item(self, item: Item[V]) -> None: + def add_item(self, item: ViewItem[V]) -> None: if item.row is not None: total = self.weights[item.row] + item.width if total > 5: @@ -174,7 +174,7 @@ def add_item(self, item: Item[V]) -> None: self.weights[index] += item.width item._rendered_row = index - def remove_item(self, item: Item[V]) -> None: + def remove_item(self, item: ViewItem[V]) -> None: if item._rendered_row is not None: self.weights[item._rendered_row] -= item.width item._rendered_row = None @@ -191,7 +191,7 @@ class BaseView(ItemInterface): def __init__( self, - *items: Item[V], + *items: ViewItem[V], timeout: float | None = 180.0, disable_on_timeout: bool = False, ): @@ -201,18 +201,18 @@ def __init__( self._message: Message | InteractionMessage | None = None self.parent: Interaction | None = None - def add_item(self, item: Item[V]) -> Self: + def add_item(self, item: ViewItem[V]) -> Self: """Adds an item to the view. Parameters ---------- - item: :class:`Item` + item: :class:`ViewItem` The item to add to the view. Raises ------ TypeError - An :class:`Item` was not passed. + An :class:`ViewItem` was not passed. ValueError Maximum number of children has been exceeded """ @@ -220,21 +220,21 @@ def add_item(self, item: Item[V]) -> Self: if len(self.children) >= self.MAX_ITEMS: raise ValueError("maximum number of children exceeded") - if not isinstance(item, Item): - raise TypeError(f"expected Item not {item.__class__!r}") + if not isinstance(item, ViewItem): + raise TypeError(f"expected ViewItem not {item.__class__!r}") item.parent = self item._view = self self.children.append(item) return self - def remove_item(self, item: Item[V] | int | str) -> None: + def remove_item(self, item: ViewItem[V] | int | str) -> None: """Removes an item from the view. If an :class:`int` or :class:`str` is passed, - the item will be removed by Item ``id`` or ``custom_id`` respectively. + the item will be removed by ViewItem ``id`` or ``custom_id`` respectively. Parameters ---------- - item: Union[:class:`Item`, :class:`int`, :class:`str`] + item: Union[:class:`ViewItem`, :class:`int`, :class:`str`] The item, item ``id``, or item ``custom_id`` to remove from the view. """ @@ -314,7 +314,7 @@ async def on_check_failure(self, interaction: Interaction) -> None: """ async def on_error( - self, error: Exception, item: Item[V], interaction: Interaction + self, error: Exception, item: ViewItem[V], interaction: Interaction ) -> None: """|coro| @@ -327,7 +327,7 @@ async def on_error( ---------- error: :class:`Exception` The exception that was raised. - item: :class:`Item` + item: :class:`ViewItem` The item that failed the dispatch. interaction: :class:`~discord.Interaction` The interaction that led to the failure. @@ -341,7 +341,7 @@ def is_components_v2(self) -> bool: """ return any([item._underlying.is_v2() for item in self.children]) - async def _scheduled_task(self, item: Item[V], interaction: Interaction): + async def _scheduled_task(self, item: ViewItem[V], interaction: Interaction): try: if self.timeout: self._timeout_expiry = time.monotonic() + self.timeout @@ -373,7 +373,7 @@ def _dispatch_timeout(self): self.on_timeout(), name=f"discord-ui-view-timeout-{self.id}" ) - def _dispatch_item(self, item: Item[V], interaction: Interaction): + def _dispatch_item(self, item: ViewItem[V], interaction: Interaction): if self._stopped.done(): return @@ -437,13 +437,13 @@ async def wait(self) -> bool: """ return await self._stopped - def disable_all_items(self, *, exclusions: list[Item[V]] | None = None) -> Self: + def disable_all_items(self, *, exclusions: list[ViewItem[V]] | None = None) -> Self: """ Disables all buttons and select menus in the view. Parameters ---------- - exclusions: Optional[List[:class:`Item`]] + exclusions: Optional[List[:class:`ViewItem`]] A list of items in `self.children` to not disable from the view. """ for child in self.children: @@ -455,13 +455,13 @@ def disable_all_items(self, *, exclusions: list[Item[V]] | None = None) -> Self: child.disable_all_items(exclusions=exclusions) return self - def enable_all_items(self, *, exclusions: list[Item[V]] | None = None) -> Self: + def enable_all_items(self, *, exclusions: list[ViewItem[V]] | None = None) -> Self: """ Enables all buttons and select menus in the view. Parameters ---------- - exclusions: Optional[List[:class:`Item`]] + exclusions: Optional[List[:class:`ViewItem`]] A list of items in `self.children` to not enable from the view. """ for child in self.children: @@ -479,7 +479,7 @@ def copy_text(self) -> str: """ return "\n".join(t for i in self.children if (t := i.copy_text())) - def walk_children(self) -> Iterator[Item]: + def walk_children(self) -> Iterator[ViewItem]: for item in self.children: if hasattr(item, "walk_items"): yield from item.walk_items() @@ -508,7 +508,7 @@ class View(BaseView): Parameters ---------- - *items: :class:`Item` + *items: :class:`ViewItem` The initial items attached to this view. timeout: Optional[:class:`float`] Timeout in seconds from last interaction with the UI before no longer accepting input. Defaults to 180.0. @@ -519,7 +519,7 @@ class View(BaseView): 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`] + children: List[:class:`ViewItem`] The list of children attached to this view. disable_on_timeout: :class:`bool` Whether to disable the view when the timeout is reached. Defaults to ``False``. @@ -548,14 +548,14 @@ def __init_subclass__(cls) -> None: def __init__( self, - *items: Item[V], + *items: ViewItem[V], timeout: float | None = 180.0, disable_on_timeout: bool = False, ): super().__init__(timeout=timeout, disable_on_timeout=disable_on_timeout) for func in self.__view_children_items__: - item: Item[V] = func.__discord_ui_model_type__( + item: ViewItem[V] = func.__discord_ui_model_type__( **func.__discord_ui_model_kwargs__ ) item.callback = partial(func, self, item) @@ -569,7 +569,7 @@ def __init__( self.add_item(item) def to_components(self) -> list[dict[str, Any]]: - def key(item: Item[V]) -> int: + def key(item: ViewItem[V]) -> int: return item._rendered_row or 0 children = sorted(self.children, key=key) @@ -646,18 +646,18 @@ def from_dict( for component in _walk_all_components(components): view.add_item(_component_to_item(component)) - def add_item(self, item: Item[V]) -> Self: + def add_item(self, item: ViewItem[V]) -> Self: """Adds an item to the view. Attempting to add a :class:`~discord.ui.ActionRow` will add its children instead. Parameters ---------- - item: :class:`Item` + item: :class:`ViewItem` The item to add to the view. Raises ------ TypeError - An :class:`Item` was not passed. + An :class:`ViewItem` was not passed. ValueError Maximum number of children has been exceeded (25) or the row the item is trying to be added to is full. @@ -676,13 +676,13 @@ def add_item(self, item: Item[V]) -> Self: self.__weights.add_item(item) return self - def remove_item(self, item: Item[V] | int | str) -> None: + def remove_item(self, item: ViewItem[V] | int | str) -> None: """Removes an item from the view. 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:`Item`, :class:`int`, :class:`str`] + item: Union[:class:`ViewItem`, :class:`int`, :class:`str`] The item, item ``id``, or item ``custom_id`` to remove from the view. """ @@ -701,10 +701,10 @@ def clear_items(self) -> None: def refresh(self, components: list[Component]): # This is pretty hacky at the moment - old_state: dict[tuple[int, str], Item[V]] = { + old_state: dict[tuple[int, str], ViewItem[V]] = { (item.type.value, item.custom_id): item for item in self.children if item.is_dispatchable() # type: ignore } - children: list[Item[V]] = [ + children: list[ViewItem[V]] = [ item for item in self.children if not item.is_dispatchable() ] for component in _walk_all_components(components): @@ -740,7 +740,7 @@ class DesignerView(BaseView): Parameters ---------- - *items: :class:`Item` + *items: :class:`ViewItem` The initial items attached to this view. timeout: Optional[:class:`float`] Timeout in seconds from last interaction with the UI before no longer accepting input. Defaults to 180.0. @@ -751,7 +751,7 @@ class DesignerView(BaseView): 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`] + children: List[:class:`ViewItem`] The list of items attached to this view. disable_on_timeout: :class:`bool` Whether to disable the view's items when the timeout is reached. Defaults to ``False``. @@ -775,7 +775,7 @@ def __init_subclass__(cls) -> None: def __init__( self, - *items: Item[V], + *items: ViewItem[V], timeout: float | None = 180.0, disable_on_timeout: bool = False, ): @@ -839,18 +839,18 @@ def from_dict( view.add_item(_component_to_item(component)) return view - def add_item(self, item: Item[V]) -> Self: + def add_item(self, item: ViewItem[V]) -> Self: """Adds an item to the view. Parameters ---------- - item: :class:`Item` + item: :class:`ViewItem` The item to add to the view. Raises ------ TypeError - An :class:`Item` was not passed. + An :class:`ViewItem` was not passed. ValueError Maximum number of items has been exceeded (40) """ @@ -887,8 +887,8 @@ def is_components_v2(self) -> bool: class ViewStore: def __init__(self, state: ConnectionState): - # (component_type, message_id, custom_id): (BaseView, Item) - self._views: dict[tuple[int, int | None, str], tuple[BaseView, Item[V]]] = {} + # (component_type, message_id, custom_id): (BaseView, ViewItem) + self._views: dict[tuple[int, int | None, str], tuple[BaseView, ViewItem[V]]] = {} # message_id: View self._synced_message_views: dict[int, BaseView] = {} self._state: ConnectionState = state diff --git a/docs/api/ui_kit.rst b/docs/api/ui_kit.rst index 41bd41dffa..2338669f0a 100644 --- a/docs/api/ui_kit.rst +++ b/docs/api/ui_kit.rst @@ -42,17 +42,31 @@ Objects .. autoclass:: discord.ui.View :members: + :inherited-members: .. attributetable:: discord.ui.DesignerView .. autoclass:: discord.ui.DesignerView :members: + :inherited-members: .. attributetable:: discord.ui.Item .. autoclass:: discord.ui.Item :members: +.. attributetable:: discord.ui.ViewItem + +.. autoclass:: discord.ui.ViewItem + :members: + :inherited-members: + +.. attributetable:: discord.ui.ModalItem + +.. autoclass:: discord.ui.ModalItem + :members: + :inherited-members: + .. attributetable:: discord.ui.ActionRow .. autoclass:: discord.ui.ActionRow From fd67ea2ee190b13b3c37ae35eb305d3020d126bb Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Fri, 3 Oct 2025 17:18:35 +0000 Subject: [PATCH 73/99] style(pre-commit): auto fixes from pre-commit.com hooks --- discord/client.py | 4 +++- discord/ui/button.py | 2 +- discord/ui/container.py | 2 +- discord/ui/modal.py | 2 +- discord/ui/section.py | 6 ++++-- discord/ui/select.py | 2 +- discord/ui/text_display.py | 2 +- discord/ui/view.py | 6 ++++-- 8 files changed, 16 insertions(+), 10 deletions(-) diff --git a/discord/client.py b/discord/client.py index 5a5c654200..bad577e711 100644 --- a/discord/client.py +++ b/discord/client.py @@ -573,7 +573,9 @@ async def on_view_error( error.__class__, error, error.__traceback__, file=sys.stderr ) - async def on_modal_error(self, error: Exception, modal: BaseModal, interaction: Interaction) -> None: + async def on_modal_error( + self, error: Exception, modal: BaseModal, interaction: Interaction + ) -> None: """|coro| The default modal error handler provided by the client. diff --git a/discord/ui/button.py b/discord/ui/button.py index e4e780d77a..1810a6c300 100644 --- a/discord/ui/button.py +++ b/discord/ui/button.py @@ -32,7 +32,7 @@ from ..components import Button as ButtonComponent from ..enums import ButtonStyle, ComponentType from ..partial_emoji import PartialEmoji, _EmojiTag -from .item import ViewItem, ItemCallbackType +from .item import ItemCallbackType, ViewItem __all__ = ( "Button", diff --git a/discord/ui/container.py b/discord/ui/container.py index cb7fae95cc..875abd59cf 100644 --- a/discord/ui/container.py +++ b/discord/ui/container.py @@ -11,7 +11,7 @@ from .action_row import ActionRow from .button import Button from .file import File -from .item import ViewItem, ItemCallbackType +from .item import ItemCallbackType, ViewItem from .media_gallery import MediaGallery from .section import Section from .select import Select diff --git a/discord/ui/modal.py b/discord/ui/modal.py index 281a7a4429..c4c6bec246 100644 --- a/discord/ui/modal.py +++ b/discord/ui/modal.py @@ -6,7 +6,7 @@ import time from functools import partial from itertools import groupby -from typing import TYPE_CHECKING, Any, TypeVar, Union +from typing import TYPE_CHECKING, Any, TypeVar from ..enums import ComponentType from ..utils import find diff --git a/discord/ui/section.py b/discord/ui/section.py index d730a31111..2e5ca96430 100644 --- a/discord/ui/section.py +++ b/discord/ui/section.py @@ -8,7 +8,7 @@ from ..enums import ComponentType from ..utils import find, get from .button import Button -from .item import ViewItem, ItemCallbackType +from .item import ItemCallbackType, ViewItem from .text_display import TextDisplay from .thumbnail import Thumbnail @@ -61,7 +61,9 @@ def __init_subclass__(cls) -> None: cls.__section_accessory_item__ = accessory - def __init__(self, *items: ViewItem, accessory: ViewItem = None, id: int | None = None): + def __init__( + self, *items: ViewItem, accessory: ViewItem = None, id: int | None = None + ): super().__init__() self.items: list[ViewItem] = [] diff --git a/discord/ui/select.py b/discord/ui/select.py index 2b562b63a0..4c59f5c086 100644 --- a/discord/ui/select.py +++ b/discord/ui/select.py @@ -44,7 +44,7 @@ from ..threads import Thread from ..user import User from ..utils import MISSING -from .item import ViewItem, ItemCallbackType +from .item import ItemCallbackType, ViewItem __all__ = ( "Select", diff --git a/discord/ui/text_display.py b/discord/ui/text_display.py index e44ecc8bb3..59ae76eaaa 100644 --- a/discord/ui/text_display.py +++ b/discord/ui/text_display.py @@ -5,7 +5,7 @@ from ..components import TextDisplay as TextDisplayComponent from ..components import _component_factory from ..enums import ComponentType -from .item import ViewItem, ModalItem +from .item import ModalItem, ViewItem __all__ = ("TextDisplay",) diff --git a/discord/ui/view.py b/discord/ui/view.py index 7c72f827d0..7110246ed1 100644 --- a/discord/ui/view.py +++ b/discord/ui/view.py @@ -50,7 +50,7 @@ from ..components import _component_factory from ..utils import find from .core import ItemInterface -from .item import ViewItem, ItemCallbackType +from .item import ItemCallbackType, ViewItem __all__ = ( "BaseView", @@ -888,7 +888,9 @@ def is_components_v2(self) -> bool: class ViewStore: def __init__(self, state: ConnectionState): # (component_type, message_id, custom_id): (BaseView, ViewItem) - self._views: dict[tuple[int, int | None, str], tuple[BaseView, ViewItem[V]]] = {} + self._views: dict[tuple[int, int | None, str], tuple[BaseView, ViewItem[V]]] = ( + {} + ) # message_id: View self._synced_message_views: dict[int, BaseView] = {} self._state: ConnectionState = state From 148535afd0d1c5973fd890054c035efc8dd76894 Mon Sep 17 00:00:00 2001 From: UK <41271523+NeloBlivion@users.noreply.github.com> Date: Fri, 3 Oct 2025 13:21:55 -0400 Subject: [PATCH 74/99] types --- discord/ui/select.py | 3 ++- discord/ui/text_display.py | 2 ++ 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/discord/ui/select.py b/discord/ui/select.py index 4c59f5c086..8c54a6a6e6 100644 --- a/discord/ui/select.py +++ b/discord/ui/select.py @@ -44,7 +44,7 @@ from ..threads import Thread from ..user import User from ..utils import MISSING -from .item import ItemCallbackType, ViewItem +from .item import ItemCallbackType, ViewItem, ModalItem __all__ = ( "Select", @@ -68,6 +68,7 @@ from ..types.components import SelectMenu as SelectMenuPayload from ..types.interactions import ComponentInteractionData from .view import BaseView + from .modal import DesignerModal ST = TypeVar("ST", bound=Snowflake | str, covariant=True, default=Any) else: diff --git a/discord/ui/text_display.py b/discord/ui/text_display.py index 59ae76eaaa..5fe6ace987 100644 --- a/discord/ui/text_display.py +++ b/discord/ui/text_display.py @@ -12,6 +12,8 @@ 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") From bcc1a7a0f0866eec1b698215bf446b25a27fb414 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Fri, 3 Oct 2025 17:22:23 +0000 Subject: [PATCH 75/99] style(pre-commit): auto fixes from pre-commit.com hooks --- discord/ui/select.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/discord/ui/select.py b/discord/ui/select.py index 8c54a6a6e6..bd86f054b3 100644 --- a/discord/ui/select.py +++ b/discord/ui/select.py @@ -44,7 +44,7 @@ from ..threads import Thread from ..user import User from ..utils import MISSING -from .item import ItemCallbackType, ViewItem, ModalItem +from .item import ItemCallbackType, ModalItem, ViewItem __all__ = ( "Select", @@ -67,8 +67,8 @@ from ..abc import GuildChannel, Snowflake from ..types.components import SelectMenu as SelectMenuPayload from ..types.interactions import ComponentInteractionData - from .view import BaseView from .modal import DesignerModal + from .view import BaseView ST = TypeVar("ST", bound=Snowflake | str, covariant=True, default=Any) else: From 712cd3c6e8a406b9ecd8bbca94bc300677f2107c Mon Sep 17 00:00:00 2001 From: UK <41271523+NeloBlivion@users.noreply.github.com> Date: Fri, 3 Oct 2025 13:24:05 -0400 Subject: [PATCH 76/99] this is required apparently --- discord/ui/select.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/discord/ui/select.py b/discord/ui/select.py index bd86f054b3..a6fce13fc5 100644 --- a/discord/ui/select.py +++ b/discord/ui/select.py @@ -82,7 +82,7 @@ M = TypeVar("M", bound="DesignerModal", covariant=True) -class Select(Generic[V, ST], ViewItem[V], ModalItem[M]): +class Select(Generic[V, M, ST], ViewItem[V], ModalItem[M]): """Represents a UI select menu. This is usually represented as a drop down menu. From ef78980e2f78fe306e5abf24b923de362de7ccfc Mon Sep 17 00:00:00 2001 From: UK <41271523+NeloBlivion@users.noreply.github.com> Date: Fri, 3 Oct 2025 13:32:22 -0400 Subject: [PATCH 77/99] self.modal --- discord/ui/label.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/discord/ui/label.py b/discord/ui/label.py index a983efe534..e5db2f3720 100644 --- a/discord/ui/label.py +++ b/discord/ui/label.py @@ -108,8 +108,8 @@ def set_item(self, item: ModalItem) -> Self: 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.view: - item._view = self.view + if self.modal: + item._modal = self.modal item.parent = self self.item = item From 9cf288691d9debb6f63a89a15baa21c7baa89498 Mon Sep 17 00:00:00 2001 From: UK <41271523+NeloBlivion@users.noreply.github.com> Date: Fri, 3 Oct 2025 13:32:32 -0400 Subject: [PATCH 78/99] set modal --- discord/ui/label.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/discord/ui/label.py b/discord/ui/label.py index e5db2f3720..e35fd02442 100644 --- a/discord/ui/label.py +++ b/discord/ui/label.py @@ -109,7 +109,7 @@ def set_item(self, item: ModalItem) -> Self: 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.modal = self.modal item.parent = self self.item = item From 62ffcdfbb1e97d51beecd047a98bdac60a252f49 Mon Sep 17 00:00:00 2001 From: UK <41271523+NeloBlivion@users.noreply.github.com> Date: Fri, 3 Oct 2025 13:35:16 -0400 Subject: [PATCH 79/99] no weights in basemodal --- discord/ui/modal.py | 1 - 1 file changed, 1 deletion(-) diff --git a/discord/ui/modal.py b/discord/ui/modal.py index c4c6bec246..45dfb6fb6b 100644 --- a/discord/ui/modal.py +++ b/discord/ui/modal.py @@ -80,7 +80,6 @@ def __init__( raise ValueError("title must be 45 characters or fewer") super().__init__(*children, timeout=timeout) self._title = title - self._weights = _ModalWeights(self._children) self.loop = asyncio.get_event_loop() def __repr__(self) -> str: From a961d4980f33aebca64456588e5a5fea802a6a25 Mon Sep 17 00:00:00 2001 From: UK <41271523+NeloBlivion@users.noreply.github.com> Date: Fri, 3 Oct 2025 13:45:01 -0400 Subject: [PATCH 80/99] undo on_modal_error change --- discord/client.py | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/discord/client.py b/discord/client.py index bad577e711..2f916fdbb7 100644 --- a/discord/client.py +++ b/discord/client.py @@ -75,7 +75,6 @@ from .poll import Poll from .soundboard import SoundboardSound from .ui.item import Item, ViewItem - from .ui.modal import BaseModal from .voice_client import VoiceProtocol __all__ = ("Client",) @@ -574,7 +573,7 @@ async def on_view_error( ) async def on_modal_error( - self, error: Exception, modal: BaseModal, interaction: Interaction + self, error: Exception, interaction: Interaction ) -> None: """|coro| @@ -587,13 +586,11 @@ async def on_modal_error( ---------- error: :class:`Exception` The exception that was raised. - modal: :class:`BaseModal` - The modal that failed the dispatch. interaction: :class:`Interaction` The interaction that was received. """ - print(f"Ignoring exception in modal {modal}:", file=sys.stderr) + print(f"Ignoring exception in modal {interaction.modal}:", file=sys.stderr) traceback.print_exception( error.__class__, error, error.__traceback__, file=sys.stderr ) From 8664272b6001f0f02842f4ce0d664d2b02184f7c Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Fri, 3 Oct 2025 17:45:34 +0000 Subject: [PATCH 81/99] style(pre-commit): auto fixes from pre-commit.com hooks --- discord/client.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/discord/client.py b/discord/client.py index 2f916fdbb7..4100b383a8 100644 --- a/discord/client.py +++ b/discord/client.py @@ -572,9 +572,7 @@ async def on_view_error( error.__class__, error, error.__traceback__, file=sys.stderr ) - async def on_modal_error( - self, error: Exception, interaction: Interaction - ) -> None: + async def on_modal_error(self, error: Exception, interaction: Interaction) -> None: """|coro| The default modal error handler provided by the client. From fbac65286b3b83d9d79a13a69d6f026354b01c8c Mon Sep 17 00:00:00 2001 From: UK <41271523+NeloBlivion@users.noreply.github.com> Date: Fri, 3 Oct 2025 13:48:46 -0400 Subject: [PATCH 82/99] define Item.type --- discord/ui/action_row.py | 4 ---- discord/ui/button.py | 4 ---- discord/ui/container.py | 4 ---- discord/ui/file.py | 4 ---- discord/ui/input_text.py | 4 ---- discord/ui/item.py | 4 +++- discord/ui/label.py | 4 ---- discord/ui/media_gallery.py | 4 ---- discord/ui/section.py | 4 ---- discord/ui/select.py | 4 ---- discord/ui/separator.py | 4 ---- discord/ui/text_display.py | 4 ---- discord/ui/thumbnail.py | 4 ---- 13 files changed, 3 insertions(+), 49 deletions(-) diff --git a/discord/ui/action_row.py b/discord/ui/action_row.py index e85276a284..5f0531de60 100644 --- a/discord/ui/action_row.py +++ b/discord/ui/action_row.py @@ -334,10 +334,6 @@ def view(self, value): item.parent = self item._view = value - @property - def type(self) -> ComponentType: - return self._underlying.type - def is_dispatchable(self) -> bool: return any(item.is_dispatchable() for item in self.children) diff --git a/discord/ui/button.py b/discord/ui/button.py index 1810a6c300..485e6ecbfc 100644 --- a/discord/ui/button.py +++ b/discord/ui/button.py @@ -263,10 +263,6 @@ def from_component(cls: type[B], button: ButtonComponent) -> B: id=button.id, ) - @property - def type(self) -> ComponentType: - return self._underlying.type - def to_component_dict(self): return self._underlying.to_dict() diff --git a/discord/ui/container.py b/discord/ui/container.py index 875abd59cf..e1120154ab 100644 --- a/discord/ui/container.py +++ b/discord/ui/container.py @@ -347,10 +347,6 @@ def view(self, value): if hasattr(item, "items") or hasattr(item, "children"): item.view = value - @property - def type(self) -> ComponentType: - return self._underlying.type - def is_dispatchable(self) -> bool: return any(item.is_dispatchable() for item in self.items) diff --git a/discord/ui/file.py b/discord/ui/file.py index 141a539310..5a1c6c5eeb 100644 --- a/discord/ui/file.py +++ b/discord/ui/file.py @@ -54,10 +54,6 @@ def __init__(self, url: str, *, spoiler: bool = False, id: int | None = None): spoiler=spoiler, ) - @property - def type(self) -> ComponentType: - return self._underlying.type - @property def url(self) -> str: """The URL of this file's media. This must be an ``attachment://`` URL that references a :class:`~discord.File`.""" diff --git a/discord/ui/input_text.py b/discord/ui/input_text.py index da335b81f8..989328f982 100644 --- a/discord/ui/input_text.py +++ b/discord/ui/input_text.py @@ -115,10 +115,6 @@ def __repr__(self) -> str: ) return f"<{self.__class__.__name__} {attrs}>" - @property - def type(self) -> ComponentType: - return self._underlying.type - @property def style(self) -> InputTextStyle: """The style of the input text field.""" diff --git a/discord/ui/item.py b/discord/ui/item.py index 2062e600f1..b3c482d8e7 100644 --- a/discord/ui/item.py +++ b/discord/ui/item.py @@ -95,7 +95,9 @@ def from_component(cls: type[I], component: Component) -> I: @property def type(self) -> ComponentType: - raise NotImplementedError + if not self._underlying: + raise NotImplementedError + return self._underlying.type def is_dispatchable(self) -> bool: return False diff --git a/discord/ui/label.py b/discord/ui/label.py index e35fd02442..115d0e6ff6 100644 --- a/discord/ui/label.py +++ b/discord/ui/label.py @@ -304,10 +304,6 @@ def set_select( return self.set_item(select) - @property - def type(self) -> ComponentType: - return self._underlying.type - @property def label(self) -> str: """The label text. Must be 45 characters or fewer.""" diff --git a/discord/ui/media_gallery.py b/discord/ui/media_gallery.py index 0d4ef03db5..d7dc1b3fda 100644 --- a/discord/ui/media_gallery.py +++ b/discord/ui/media_gallery.py @@ -105,10 +105,6 @@ def add_item( return self.append_item(item) - @property - def type(self) -> ComponentType: - return self._underlying.type - def to_component_dict(self) -> MediaGalleryComponentPayload: return self._underlying.to_dict() diff --git a/discord/ui/section.py b/discord/ui/section.py index 2e5ca96430..7c39e94f1e 100644 --- a/discord/ui/section.py +++ b/discord/ui/section.py @@ -249,10 +249,6 @@ def copy_text(self) -> str: """ return "\n".join(t for i in self.items if (t := i.copy_text())) - @property - def type(self) -> ComponentType: - return self._underlying.type - def is_dispatchable(self) -> bool: return self.accessory and self.accessory.is_dispatchable() diff --git a/discord/ui/select.py b/discord/ui/select.py index a6fce13fc5..96967556cb 100644 --- a/discord/ui/select.py +++ b/discord/ui/select.py @@ -749,10 +749,6 @@ def from_component(cls: type[S], component: SelectMenu) -> S: default_values=component.default_values, ) # type: ignore - @property - def type(self) -> ComponentType: - return self._underlying.type - def is_dispatchable(self) -> bool: return True diff --git a/discord/ui/separator.py b/discord/ui/separator.py index 771dd7f123..a6e7a9a799 100644 --- a/discord/ui/separator.py +++ b/discord/ui/separator.py @@ -55,10 +55,6 @@ def __init__( spacing=spacing, ) - @property - def type(self) -> ComponentType: - return self._underlying.type - @property def divider(self) -> bool: """Whether the separator is a divider. Defaults to ``True``.""" diff --git a/discord/ui/text_display.py b/discord/ui/text_display.py index 5fe6ace987..3e5bb325ba 100644 --- a/discord/ui/text_display.py +++ b/discord/ui/text_display.py @@ -52,10 +52,6 @@ def __init__( content=content, ) - @property - def type(self) -> ComponentType: - return self._underlying.type - @property def content(self) -> str: """The text display's content.""" diff --git a/discord/ui/thumbnail.py b/discord/ui/thumbnail.py index 4f9a947e64..b72b80d17f 100644 --- a/discord/ui/thumbnail.py +++ b/discord/ui/thumbnail.py @@ -62,10 +62,6 @@ def __init__( spoiler=spoiler, ) - @property - def type(self) -> ComponentType: - return self._underlying.type - @property def url(self) -> str: """The URL of this thumbnail's media. This can either be an arbitrary URL or an ``attachment://`` URL.""" From c2bd6b5ca8493cca6671d00ee22365cdbe92d252 Mon Sep 17 00:00:00 2001 From: UK <41271523+NeloBlivion@users.noreply.github.com> Date: Fri, 3 Oct 2025 14:06:52 -0400 Subject: [PATCH 83/99] further consolidate --- discord/ui/action_row.py | 2 +- discord/ui/button.py | 5 +++-- discord/ui/container.py | 2 +- discord/ui/file.py | 2 +- discord/ui/input_text.py | 11 +++++------ discord/ui/item.py | 4 +++- discord/ui/label.py | 8 ++++---- discord/ui/media_gallery.py | 2 +- discord/ui/section.py | 2 +- discord/ui/select.py | 2 +- discord/ui/separator.py | 2 +- discord/ui/text_display.py | 2 +- discord/ui/thumbnail.py | 2 +- 13 files changed, 24 insertions(+), 22 deletions(-) diff --git a/discord/ui/action_row.py b/discord/ui/action_row.py index 5f0531de60..76c252b78c 100644 --- a/discord/ui/action_row.py +++ b/discord/ui/action_row.py @@ -389,7 +389,7 @@ def walk_items(self) -> Iterator[ViewItem]: def to_component_dict(self) -> ActionRowPayload: self._set_components(self.items) - return self._underlying.to_dict() + return super().to_component_dict() @classmethod def from_component(cls: type[A], component: ActionRowComponent) -> A: diff --git a/discord/ui/button.py b/discord/ui/button.py index 485e6ecbfc..bec1f94b04 100644 --- a/discord/ui/button.py +++ b/discord/ui/button.py @@ -42,6 +42,7 @@ if TYPE_CHECKING: from ..emoji import AppEmoji, GuildEmoji from .view import BaseView + from ..types.components import ButtonComponent as ButtonComponentPayload B = TypeVar("B", bound="Button") V = TypeVar("V", bound="BaseView", covariant=True) @@ -263,8 +264,8 @@ def from_component(cls: type[B], button: ButtonComponent) -> B: id=button.id, ) - def to_component_dict(self): - return self._underlying.to_dict() + def to_component_dict(self) -> ButtonComponentPayload: + return super().to_component_dict() def is_dispatchable(self) -> bool: return self.custom_id is not None diff --git a/discord/ui/container.py b/discord/ui/container.py index e1120154ab..7e635ce4e5 100644 --- a/discord/ui/container.py +++ b/discord/ui/container.py @@ -402,7 +402,7 @@ def walk_items(self) -> Iterator[ViewItem]: def to_component_dict(self) -> ContainerComponentPayload: self._set_components(self.items) - return self._underlying.to_dict() + return super().to_component_dict() @classmethod def from_component(cls: type[C], component: ContainerComponent) -> C: diff --git a/discord/ui/file.py b/discord/ui/file.py index 5a1c6c5eeb..9d56773575 100644 --- a/discord/ui/file.py +++ b/discord/ui/file.py @@ -88,7 +88,7 @@ def refresh_component(self, component: FileComponent) -> None: self._underlying = component def to_component_dict(self) -> FileComponentPayload: - return self._underlying.to_dict() + return super().to_component_dict() @classmethod def from_component(cls: type[F], component: FileComponent) -> F: diff --git a/discord/ui/input_text.py b/discord/ui/input_text.py index 989328f982..4815c8d737 100644 --- a/discord/ui/input_text.py +++ b/discord/ui/input_text.py @@ -120,11 +120,6 @@ def style(self) -> InputTextStyle: """The style of the input text field.""" return self._underlying.style - @property - def id(self) -> int | None: - """The input text's ID. If not provided by the user, it is set sequentially by Discord.""" - return self._underlying.id - @style.setter def style(self, value: InputTextStyle): if not isinstance(value, InputTextStyle): @@ -225,8 +220,12 @@ def value(self, value: str | None): 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 self._underlying.to_dict() + return super().to_component_dict() def refresh_state(self, data) -> None: self._input_value = data["value"] diff --git a/discord/ui/item.py b/discord/ui/item.py index b3c482d8e7..c6ea237f9c 100644 --- a/discord/ui/item.py +++ b/discord/ui/item.py @@ -81,7 +81,9 @@ def __init__(self): self.parent: Item | ItemInterface | None = None def to_component_dict(self) -> dict[str, Any]: - raise NotImplementedError + if not self._underlying: + raise NotImplementedError + return self._underlying.to_dict() def refresh_component(self, component: Component) -> None: self._underlying = component diff --git a/discord/ui/label.py b/discord/ui/label.py index 115d0e6ff6..3b90a7e317 100644 --- a/discord/ui/label.py +++ b/discord/ui/label.py @@ -20,7 +20,7 @@ from ..emoji import AppEmoji, GuildEmoji from ..interaction import Interaction from ..partial_emoji import PartialEmoji, _EmojiTag - from ..types.components import Label as LabelPayload + from ..types.components import LabelComponent as LabelComponentPayload from .modal import DesignerModal @@ -335,11 +335,11 @@ def refresh_component(self, component: LabelComponent) -> None: def walk_items(self) -> Iterator[ModalItem]: yield from [self.item] - def to_component_dict(self) -> LabelPayload: + def to_component_dict(self) -> LabelComponentPayload: self._set_component_from_item(self.item) - return self._underlying.to_dict() + return super().to_component_dict() - def refresh_from_modal(self, interaction: Interaction, data: LabelPayload) -> None: + def refresh_from_modal(self, interaction: Interaction, data: LabelComponentPayload) -> None: return self.item.refresh_from_modal(interaction, data.get("component", {})) @classmethod diff --git a/discord/ui/media_gallery.py b/discord/ui/media_gallery.py index d7dc1b3fda..8b027d7546 100644 --- a/discord/ui/media_gallery.py +++ b/discord/ui/media_gallery.py @@ -106,7 +106,7 @@ def add_item( return self.append_item(item) def to_component_dict(self) -> MediaGalleryComponentPayload: - return self._underlying.to_dict() + return super().to_component_dict() @classmethod def from_component(cls: type[M], component: MediaGalleryComponent) -> M: diff --git a/discord/ui/section.py b/discord/ui/section.py index 7c39e94f1e..92f6dd26a7 100644 --- a/discord/ui/section.py +++ b/discord/ui/section.py @@ -309,7 +309,7 @@ def to_component_dict(self) -> SectionComponentPayload: self._set_components(self.items) if self.accessory: self.set_accessory(self.accessory) - return self._underlying.to_dict() + return super().to_component_dict() @classmethod def from_component(cls: type[S], component: SectionComponent) -> S: diff --git a/discord/ui/select.py b/discord/ui/select.py index 96967556cb..2dfe773622 100644 --- a/discord/ui/select.py +++ b/discord/ui/select.py @@ -714,7 +714,7 @@ def width(self) -> int: return 5 def to_component_dict(self) -> SelectMenuPayload: - return self._underlying.to_dict() + return super().to_component_dict() def refresh_component(self, component: SelectMenu) -> None: self._underlying = component diff --git a/discord/ui/separator.py b/discord/ui/separator.py index a6e7a9a799..fd09afa4e0 100644 --- a/discord/ui/separator.py +++ b/discord/ui/separator.py @@ -74,7 +74,7 @@ def spacing(self, value: SeparatorSpacingSize) -> None: self._underlying.spacing = value def to_component_dict(self) -> SeparatorComponentPayload: - return self._underlying.to_dict() + return super().to_component_dict() @classmethod def from_component(cls: type[S], component: SeparatorComponent) -> S: diff --git a/discord/ui/text_display.py b/discord/ui/text_display.py index 3e5bb325ba..77656051bb 100644 --- a/discord/ui/text_display.py +++ b/discord/ui/text_display.py @@ -62,7 +62,7 @@ def content(self, value: str) -> None: self._underlying.content = value def to_component_dict(self) -> TextDisplayComponentPayload: - return self._underlying.to_dict() + 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.""" diff --git a/discord/ui/thumbnail.py b/discord/ui/thumbnail.py index b72b80d17f..57f2edf768 100644 --- a/discord/ui/thumbnail.py +++ b/discord/ui/thumbnail.py @@ -91,7 +91,7 @@ def spoiler(self, spoiler: bool) -> None: self._underlying.spoiler = spoiler def to_component_dict(self) -> ThumbnailComponentPayload: - return self._underlying.to_dict() + return super().to_component_dict() @classmethod def from_component(cls: type[T], component: ThumbnailComponent) -> T: From 9255c39c3a724a1e16923315c75eab4e8a7166e1 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Fri, 3 Oct 2025 18:07:22 +0000 Subject: [PATCH 84/99] style(pre-commit): auto fixes from pre-commit.com hooks --- discord/ui/button.py | 2 +- discord/ui/label.py | 4 +++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/discord/ui/button.py b/discord/ui/button.py index bec1f94b04..922a00c514 100644 --- a/discord/ui/button.py +++ b/discord/ui/button.py @@ -41,8 +41,8 @@ if TYPE_CHECKING: from ..emoji import AppEmoji, GuildEmoji - from .view import BaseView from ..types.components import ButtonComponent as ButtonComponentPayload + from .view import BaseView B = TypeVar("B", bound="Button") V = TypeVar("V", bound="BaseView", covariant=True) diff --git a/discord/ui/label.py b/discord/ui/label.py index 3b90a7e317..46ea2db3ab 100644 --- a/discord/ui/label.py +++ b/discord/ui/label.py @@ -339,7 +339,9 @@ 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: + def refresh_from_modal( + self, interaction: Interaction, data: LabelComponentPayload + ) -> None: return self.item.refresh_from_modal(interaction, data.get("component", {})) @classmethod From 8fdc3f12e3b8938bccfa96ce7f3e5803efd8978a Mon Sep 17 00:00:00 2001 From: UK <41271523+NeloBlivion@users.noreply.github.com> Date: Fri, 3 Oct 2025 14:12:52 -0400 Subject: [PATCH 85/99] oops --- discord/ui/modal.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/discord/ui/modal.py b/discord/ui/modal.py index 45dfb6fb6b..4ddf76646d 100644 --- a/discord/ui/modal.py +++ b/discord/ui/modal.py @@ -231,7 +231,7 @@ async def on_error(self, error: Exception, interaction: Interaction) -> None: interaction: :class:`~discord.Interaction` The interaction that led to the failure. """ - interaction.client.dispatch("modal_error", error, self, interaction) + interaction.client.dispatch("modal_error", error, interaction) async def on_timeout(self) -> None: """|coro| From 8a9fe8f6cd4b4c460d01cc96a168c6537c3529b6 Mon Sep 17 00:00:00 2001 From: UK <41271523+NeloBlivion@users.noreply.github.com> Date: Fri, 3 Oct 2025 14:20:23 -0400 Subject: [PATCH 86/99] fix modal init --- discord/ui/modal.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/discord/ui/modal.py b/discord/ui/modal.py index 4ddf76646d..ed84e20508 100644 --- a/discord/ui/modal.py +++ b/discord/ui/modal.py @@ -78,7 +78,7 @@ def __init__( 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") - super().__init__(*children, timeout=timeout) + super().__init__(timeout=timeout) self._title = title self.loop = asyncio.get_event_loop() From 38352c2c870c4b82acc5b7485048ee09a89a7dd1 Mon Sep 17 00:00:00 2001 From: UK <41271523+NeloBlivion@users.noreply.github.com> Date: Fri, 3 Oct 2025 14:28:39 -0400 Subject: [PATCH 87/99] actual fix --- discord/ui/modal.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/discord/ui/modal.py b/discord/ui/modal.py index ed84e20508..8f0ea92171 100644 --- a/discord/ui/modal.py +++ b/discord/ui/modal.py @@ -70,7 +70,6 @@ def __init__( custom_id: str | None = None, timeout: float | None = None, ) -> None: - self._children: list[ModalItem] = list(children) 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__}" @@ -79,6 +78,8 @@ def __init__( if len(title) > 45: raise ValueError("title must be 45 characters or fewer") super().__init__(timeout=timeout) + for item in children: + self.add_item(item) self._title = title self.loop = asyncio.get_event_loop() From 1d790da26197f856207890b508fb8f741d9b21d8 Mon Sep 17 00:00:00 2001 From: UK <41271523+NeloBlivion@users.noreply.github.com> Date: Fri, 3 Oct 2025 14:31:20 -0400 Subject: [PATCH 88/99] forgot modal children thing --- discord/ui/modal.py | 1 + 1 file changed, 1 insertion(+) diff --git a/discord/ui/modal.py b/discord/ui/modal.py index 8f0ea92171..36cfa2d824 100644 --- a/discord/ui/modal.py +++ b/discord/ui/modal.py @@ -77,6 +77,7 @@ def __init__( 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) for item in children: self.add_item(item) From 846b14d6b3c811fbdc24ef35dcf5b567c24513fa Mon Sep 17 00:00:00 2001 From: UK <41271523+NeloBlivion@users.noreply.github.com> Date: Fri, 3 Oct 2025 14:31:52 -0400 Subject: [PATCH 89/99] clarify --- discord/ui/modal.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/discord/ui/modal.py b/discord/ui/modal.py index 36cfa2d824..8177d07e7f 100644 --- a/discord/ui/modal.py +++ b/discord/ui/modal.py @@ -124,7 +124,7 @@ def title(self, value: str): @property def children(self) -> list[ModalItem]: - """The child components associated with the modal.""" + """The child items attached to the modal.""" return self._children @children.setter From 157d8c73b4b135d9ece8933d8e979747061f913a Mon Sep 17 00:00:00 2001 From: UK <41271523+NeloBlivion@users.noreply.github.com> Date: Sat, 4 Oct 2025 13:25:30 -0400 Subject: [PATCH 90/99] adjust item docs --- discord/ui/item.py | 23 ++++++----------------- 1 file changed, 6 insertions(+), 17 deletions(-) diff --git a/discord/ui/item.py b/discord/ui/item.py index c6ea237f9c..bd76ad1b89 100644 --- a/discord/ui/item.py +++ b/discord/ui/item.py @@ -43,7 +43,7 @@ from .view import BaseView I = TypeVar("I", bound="Item") -T = TypeVar("IF", bound="ItemInterface", covariant=True) +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]] @@ -52,25 +52,10 @@ class Item(Generic[T]): """Represents the base UI item that all UI components inherit from. - 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` - .. versionadded:: 2.0 .. versionchanged:: 2.7 - Added V2 Components. + Now used as base class for :class:`ViewItem` and :class:`ModalItem`. """ __item_repr_attributes__: tuple[str, ...] = ("id",) @@ -253,6 +238,10 @@ class ModalItem(Item[M]): - :class:`discord.ui.Label` - :class:`discord.ui.TextDisplay` + And :class:`discord.ui.Label` should be used in :class:`discord.ui.DesignerModal` to support the following items: + - :class:`discord.ui.InputText` + - :class:`discord.ui.Select` + .. versionadded:: 2.7 """ From fb01cfe8545c077818c8e06c58d601a322d68fc3 Mon Sep 17 00:00:00 2001 From: UK <41271523+NeloBlivion@users.noreply.github.com> Date: Sun, 5 Oct 2025 10:11:31 -0400 Subject: [PATCH 91/99] copyright --- discord/ui/action_row.py | 24 ++++++++++++++++++++++++ discord/ui/container.py | 24 ++++++++++++++++++++++++ discord/ui/file.py | 24 ++++++++++++++++++++++++ discord/ui/input_text.py | 24 ++++++++++++++++++++++++ discord/ui/label.py | 24 ++++++++++++++++++++++++ discord/ui/media_gallery.py | 24 ++++++++++++++++++++++++ discord/ui/modal.py | 24 ++++++++++++++++++++++++ discord/ui/section.py | 24 ++++++++++++++++++++++++ discord/ui/separator.py | 24 ++++++++++++++++++++++++ discord/ui/text_display.py | 24 ++++++++++++++++++++++++ discord/ui/thumbnail.py | 24 ++++++++++++++++++++++++ 11 files changed, 264 insertions(+) diff --git a/discord/ui/action_row.py b/discord/ui/action_row.py index 76c252b78c..9f93db8c97 100644 --- a/discord/ui/action_row.py +++ b/discord/ui/action_row.py @@ -1,3 +1,27 @@ +""" +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 diff --git a/discord/ui/container.py b/discord/ui/container.py index 7e635ce4e5..3b852d0e56 100644 --- a/discord/ui/container.py +++ b/discord/ui/container.py @@ -1,3 +1,27 @@ +""" +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 diff --git a/discord/ui/file.py b/discord/ui/file.py index 9d56773575..32e1ac2f45 100644 --- a/discord/ui/file.py +++ b/discord/ui/file.py @@ -1,3 +1,27 @@ +""" +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 diff --git a/discord/ui/input_text.py b/discord/ui/input_text.py index 4815c8d737..80845c5728 100644 --- a/discord/ui/input_text.py +++ b/discord/ui/input_text.py @@ -1,3 +1,27 @@ +""" +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 diff --git a/discord/ui/label.py b/discord/ui/label.py index 46ea2db3ab..aff100862f 100644 --- a/discord/ui/label.py +++ b/discord/ui/label.py @@ -1,3 +1,27 @@ +""" +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 diff --git a/discord/ui/media_gallery.py b/discord/ui/media_gallery.py index 8b027d7546..34a4282a37 100644 --- a/discord/ui/media_gallery.py +++ b/discord/ui/media_gallery.py @@ -1,3 +1,27 @@ +""" +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 diff --git a/discord/ui/modal.py b/discord/ui/modal.py index 8177d07e7f..b3b4527bf0 100644 --- a/discord/ui/modal.py +++ b/discord/ui/modal.py @@ -1,3 +1,27 @@ +""" +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 diff --git a/discord/ui/section.py b/discord/ui/section.py index 92f6dd26a7..79d092595c 100644 --- a/discord/ui/section.py +++ b/discord/ui/section.py @@ -1,3 +1,27 @@ +""" +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 diff --git a/discord/ui/separator.py b/discord/ui/separator.py index fd09afa4e0..2ddfae8af2 100644 --- a/discord/ui/separator.py +++ b/discord/ui/separator.py @@ -1,3 +1,27 @@ +""" +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 diff --git a/discord/ui/text_display.py b/discord/ui/text_display.py index 77656051bb..76ab9dbc50 100644 --- a/discord/ui/text_display.py +++ b/discord/ui/text_display.py @@ -1,3 +1,27 @@ +""" +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 diff --git a/discord/ui/thumbnail.py b/discord/ui/thumbnail.py index 57f2edf768..61c44bd2ce 100644 --- a/discord/ui/thumbnail.py +++ b/discord/ui/thumbnail.py @@ -1,3 +1,27 @@ +""" +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 340b5d51700b722ef2f3a087bf9c6c35357077cc Mon Sep 17 00:00:00 2001 From: UK <41271523+NeloBlivion@users.noreply.github.com> Date: Sun, 5 Oct 2025 10:37:12 -0400 Subject: [PATCH 92/99] children --- discord/ui/action_row.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/discord/ui/action_row.py b/discord/ui/action_row.py index 9f93db8c97..292fd7d818 100644 --- a/discord/ui/action_row.py +++ b/discord/ui/action_row.py @@ -366,11 +366,9 @@ def is_persistent(self) -> bool: def refresh_component(self, component: ActionRowComponent) -> None: self._underlying = component - i = 0 - for y in component.components: + for i, y in enumerate(component.components): x = self.children[i] x.refresh_component(y) - i += 1 def disable_all_items(self, *, exclusions: list[ViewItem] | None = None) -> Self: """ @@ -412,7 +410,7 @@ def walk_items(self) -> Iterator[ViewItem]: yield from self.children def to_component_dict(self) -> ActionRowPayload: - self._set_components(self.items) + self._set_components(self.children) return super().to_component_dict() @classmethod From 3f3ae31ed9e2dcfd8e714db99813403cd4f0d5e4 Mon Sep 17 00:00:00 2001 From: UK <41271523+NeloBlivion@users.noreply.github.com> Date: Mon, 6 Oct 2025 14:40:55 +0100 Subject: [PATCH 93/99] Update discord/ui/action_row.py Co-authored-by: Timo <35654063+tibue99@users.noreply.github.com> Signed-off-by: UK <41271523+NeloBlivion@users.noreply.github.com> --- discord/ui/action_row.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/discord/ui/action_row.py b/discord/ui/action_row.py index 292fd7d818..d3607867b7 100644 --- a/discord/ui/action_row.py +++ b/discord/ui/action_row.py @@ -71,7 +71,7 @@ class ActionRow(ViewItem[V]): """ __item_repr_attributes__: tuple[str, ...] = ( - "items", + "children", "id", ) From be9eb51602b9c952bd71c4b931b1a376adc6dd2c Mon Sep 17 00:00:00 2001 From: UK <41271523+NeloBlivion@users.noreply.github.com> Date: Mon, 6 Oct 2025 12:17:27 -0400 Subject: [PATCH 94/99] add Webhook.parent --- discord/interactions.py | 4 ++-- discord/webhook/async_.py | 11 +++++++++++ 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/discord/interactions.py b/discord/interactions.py index 8703306dd6..db93ca7aaf 100644 --- a/discord/interactions.py +++ b/discord/interactions.py @@ -403,7 +403,7 @@ def followup(self) -> Webhook: "type": 3, "token": self.token, } - return Webhook.from_state(data=payload, state=self._state) + return Webhook.from_state(data=payload, state=self._state, parent=self) def is_guild_authorised(self) -> bool: """:class:`bool`: Checks if the interaction is guild authorised. @@ -1105,11 +1105,11 @@ async def send_message( self._responded = True await self._process_callback_response(callback_response) if view: + view.parent = self._parent if not view.is_finished(): if ephemeral and view.timeout is None: view.timeout = 15 * 60.0 - view.parent = self._parent if view.is_dispatchable(): self._parent._state.store_view(view) diff --git a/discord/webhook/async_.py b/discord/webhook/async_.py index 58f99471fa..cfa4154c52 100644 --- a/discord/webhook/async_.py +++ b/discord/webhook/async_.py @@ -74,6 +74,7 @@ from ..file import File from ..guild import Guild from ..http import Response + from ..interactions import Interaction from ..mentions import AllowedMentions from ..poll import Poll from ..state import ConnectionState @@ -1203,6 +1204,12 @@ async def foo(): Only given if :attr:`type` is :attr:`WebhookType.channel_follower`. .. versionadded:: 2.0 + + parent: Optional[:class:`Interaction`] + The interaction this webhook belongs to. + Only set if :attr:`type` is :attr:`WebhookType.application`. + + .. versionadded:: 2.7 """ __slots__: tuple[str, ...] = ("session", "proxy", "proxy_auth") @@ -1215,11 +1222,13 @@ def __init__( proxy_auth: aiohttp.BasicAuth | None = None, token: str | None = None, state=None, + parent: Interaction | None = None ): super().__init__(data, token, state) self.session = session self.proxy: str | None = proxy self.proxy_auth: aiohttp.BasicAuth | None = proxy_auth + self.parent: Interaction | None = parent def __repr__(self): return f"" @@ -1867,6 +1876,8 @@ async def send( if view and not view.is_finished(): message_id = None if msg is None else msg.id view.message = None if msg is None else msg + if self.parent and not view.parent: + view.parent = self.parent if msg: view.refresh(msg.components) if view.is_dispatchable(): From cf49aa3adb501cc4e8048eb4c4fdff1226edfb76 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Mon, 6 Oct 2025 16:17:57 +0000 Subject: [PATCH 95/99] style(pre-commit): auto fixes from pre-commit.com hooks --- discord/webhook/async_.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/discord/webhook/async_.py b/discord/webhook/async_.py index cfa4154c52..a4e1e91a4b 100644 --- a/discord/webhook/async_.py +++ b/discord/webhook/async_.py @@ -1222,7 +1222,7 @@ def __init__( proxy_auth: aiohttp.BasicAuth | None = None, token: str | None = None, state=None, - parent: Interaction | None = None + parent: Interaction | None = None, ): super().__init__(data, token, state) self.session = session From 017f15fe6fb086113de5b5cd71203c24ac8f98cf Mon Sep 17 00:00:00 2001 From: UK <41271523+NeloBlivion@users.noreply.github.com> Date: Mon, 6 Oct 2025 12:22:31 -0400 Subject: [PATCH 96/99] add Webhook.from_interaction --- discord/interactions.py | 7 +------ discord/webhook/async_.py | 22 ++++++++++++++++++++++ 2 files changed, 23 insertions(+), 6 deletions(-) diff --git a/discord/interactions.py b/discord/interactions.py index db93ca7aaf..aaf1dee386 100644 --- a/discord/interactions.py +++ b/discord/interactions.py @@ -398,12 +398,7 @@ def response(self) -> InteractionResponse: @utils.cached_slot_property("_cs_followup") def followup(self) -> Webhook: """Returns the followup webhook for followup interactions.""" - payload = { - "id": self.application_id, - "type": 3, - "token": self.token, - } - return Webhook.from_state(data=payload, state=self._state, parent=self) + return Webhook.from_interaction(interaction=self) def is_guild_authorised(self) -> bool: """:class:`bool`: Checks if the interaction is guild authorised. diff --git a/discord/webhook/async_.py b/discord/webhook/async_.py index a4e1e91a4b..e3cfb32a32 100644 --- a/discord/webhook/async_.py +++ b/discord/webhook/async_.py @@ -1380,6 +1380,28 @@ def from_state(cls, data, state) -> Webhook: token=state.http.token, ) + @classmethod + def from_interaction(cls, interaction) -> Webhook: + state = interaction._state + data = { + "id": interaction.application_id, + "type": 3, + "token": interaction.token, + } + http = state.http + session = http._HTTPClient__session + proxy_auth = http.proxy_auth + proxy = http.proxy + return cls( + data, + session=session, + state=state, + proxy_auth=proxy_auth, + proxy=proxy, + token=state.http.token, + parent=interaction, + ) + async def fetch(self, *, prefer_auth: bool = True) -> Webhook: """|coro| From bba29afbe5ef136e1468708027ca800fbce19b09 Mon Sep 17 00:00:00 2001 From: UK <41271523+NeloBlivion@users.noreply.github.com> Date: Mon, 6 Oct 2025 12:24:01 -0400 Subject: [PATCH 97/99] slots --- discord/webhook/async_.py | 1 + 1 file changed, 1 insertion(+) diff --git a/discord/webhook/async_.py b/discord/webhook/async_.py index e3cfb32a32..c9093f7781 100644 --- a/discord/webhook/async_.py +++ b/discord/webhook/async_.py @@ -1037,6 +1037,7 @@ class BaseWebhook(Hashable): "source_channel", "source_guild", "_state", + "parent", ) def __init__( From 9bea01ef276bf0592d28934d6f1a952379f45712 Mon Sep 17 00:00:00 2001 From: UK <41271523+NeloBlivion@users.noreply.github.com> Date: Thu, 9 Oct 2025 03:19:46 -0400 Subject: [PATCH 98/99] fix flag logic --- discord/interactions.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/discord/interactions.py b/discord/interactions.py index aaf1dee386..159e1c7fdf 100644 --- a/discord/interactions.py +++ b/discord/interactions.py @@ -1186,6 +1186,7 @@ async def edit_message( if parent.type not in (InteractionType.component, InteractionType.modal_submit): return + flags = MessageFlags._from_value(self._parent.message.flags.value) payload = {} if content is not MISSING: payload["content"] = None if content is None else str(content) @@ -1203,6 +1204,8 @@ async def edit_message( if view is not MISSING: state.prevent_view_updates_for(message_id) payload["components"] = [] if view is None else view.to_components() + if view and view.is_components_v2(): + flags.is_components_v2 = True if file is not MISSING and files is not MISSING: raise InvalidArgument( @@ -1230,7 +1233,6 @@ async def edit_message( payload["attachments"] = [a.to_dict() for a in msg.attachments] if suppress is not MISSING: - flags = MessageFlags._from_value(self._parent.message.flags.value) flags.suppress_embeds = suppress payload["flags"] = flags.value From bab2b14a846ced4526bd0d3c41f3ce8ff341fcb1 Mon Sep 17 00:00:00 2001 From: UK <41271523+NeloBlivion@users.noreply.github.com> Date: Thu, 9 Oct 2025 03:30:36 -0400 Subject: [PATCH 99/99] better impl --- discord/interactions.py | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/discord/interactions.py b/discord/interactions.py index 159e1c7fdf..0312a2cc72 100644 --- a/discord/interactions.py +++ b/discord/interactions.py @@ -1186,7 +1186,8 @@ async def edit_message( if parent.type not in (InteractionType.component, InteractionType.modal_submit): return - flags = MessageFlags._from_value(self._parent.message.flags.value) + is_cv2 = None + payload = {} if content is not MISSING: payload["content"] = None if content is None else str(content) @@ -1205,7 +1206,7 @@ async def edit_message( state.prevent_view_updates_for(message_id) payload["components"] = [] if view is None else view.to_components() if view and view.is_components_v2(): - flags.is_components_v2 = True + is_cv2 = True if file is not MISSING and files is not MISSING: raise InvalidArgument( @@ -1232,8 +1233,12 @@ async def edit_message( # we keep previous attachments when adding new files payload["attachments"] = [a.to_dict() for a in msg.attachments] - if suppress is not MISSING: - flags.suppress_embeds = suppress + if suppress is not MISSING or is_cv2: + flags = MessageFlags._from_value(msg.flags.value) + if is_cv2: + flags.is_components_v2 = True + if suppress is not MISSING: + flags.suppress_embeds = suppress payload["flags"] = flags.value if allowed_mentions is None: