Skip to content

Commit 57282b8

Browse files
authored
))
1 parent c6c4fbf commit 57282b8

File tree

2 files changed

+300
-2
lines changed

2 files changed

+300
-2
lines changed

discord/ui/modal.py

Lines changed: 300 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,9 +14,12 @@
1414
from .item import Item
1515
from .select import Select
1616
from .text_display import TextDisplay
17+
from .core import ComponentUI
1718

1819
__all__ = (
20+
"BaseModal",
1921
"Modal",
22+
"DesignerModal",
2023
"ModalStore",
2124
)
2225

@@ -31,8 +34,304 @@
3134

3235
ModalItem = Union[InputText, Item[M]]
3336

37+
class BaseModal(ComponentUI):
38+
"""Represents a UI Modal dialog.
39+
40+
This object must be inherited to create a UI within Discord.
41+
42+
.. versionadded:: 2.7
43+
44+
Parameters
45+
----------
46+
children: Union[:class:`Item`]
47+
The initial items that are displayed in the modal dialog.
48+
title: :class:`str`
49+
The title of the modal dialog.
50+
Must be 45 characters or fewer.
51+
custom_id: Optional[:class:`str`]
52+
The ID of the modal dialog that gets received during an interaction.
53+
Must be 100 characters or fewer.
54+
timeout: Optional[:class:`float`]
55+
Timeout in seconds from last interaction with the UI before no longer accepting input.
56+
If ``None`` then there is no timeout.
57+
"""
58+
59+
__item_repr_attributes__: tuple[str, ...] = (
60+
"title",
61+
"children",
62+
"timeout",
63+
)
64+
65+
def __init__(
66+
self,
67+
*children: ModalItem,
68+
title: str,
69+
custom_id: str | None = None,
70+
timeout: float | None = None,
71+
) -> None:
72+
self.timeout: float | None = timeout
73+
if not isinstance(custom_id, str) and custom_id is not None:
74+
raise TypeError(
75+
f"expected custom_id to be str, not {custom_id.__class__.__name__}"
76+
)
77+
self._custom_id: str | None = custom_id or os.urandom(16).hex()
78+
if len(title) > 45:
79+
raise ValueError("title must be 45 characters or fewer")
80+
self._title = title
81+
self._children: list[ModalItem] = list(children)
82+
self._weights = _ModalWeights(self._children)
83+
loop = asyncio.get_running_loop()
84+
self._stopped: asyncio.Future[bool] = loop.create_future()
85+
self.__cancel_callback: Callable[[Modal], None] | None = None
86+
self.__timeout_expiry: float | None = None
87+
self.__timeout_task: asyncio.Task[None] | None = None
88+
self.loop = asyncio.get_event_loop()
89+
90+
def __repr__(self) -> str:
91+
attrs = " ".join(
92+
f"{key}={getattr(self, key)!r}" for key in self.__item_repr_attributes__
93+
)
94+
return f"<{self.__class__.__name__} {attrs}>"
95+
96+
def _start_listening_from_store(self, store: ModalStore) -> None:
97+
self.__cancel_callback = partial(store.remove_modal)
98+
if self.timeout:
99+
loop = asyncio.get_running_loop()
100+
if self.__timeout_task is not None:
101+
self.__timeout_task.cancel()
102+
103+
self.__timeout_expiry = time.monotonic() + self.timeout
104+
self.__timeout_task = loop.create_task(self.__timeout_task_impl())
105+
106+
async def __timeout_task_impl(self) -> None:
107+
while True:
108+
# Guard just in case someone changes the value of the timeout at runtime
109+
if self.timeout is None:
110+
return
111+
112+
if self.__timeout_expiry is None:
113+
return self._dispatch_timeout()
114+
115+
# Check if we've elapsed our currently set timeout
116+
now = time.monotonic()
117+
if now >= self.__timeout_expiry:
118+
return self._dispatch_timeout()
119+
120+
# Wait N seconds to see if timeout data has been refreshed
121+
await asyncio.sleep(self.__timeout_expiry - now)
122+
123+
@property
124+
def _expires_at(self) -> float | None:
125+
if self.timeout:
126+
return time.monotonic() + self.timeout
127+
return None
128+
129+
def _dispatch_timeout(self):
130+
if self._stopped.done():
131+
return
132+
133+
self._stopped.set_result(True)
134+
self.loop.create_task(
135+
self.on_timeout(), name=f"discord-ui-view-timeout-{self.custom_id}"
136+
)
137+
138+
@property
139+
def title(self) -> str:
140+
"""The title of the modal dialog."""
141+
return self._title
142+
143+
@title.setter
144+
def title(self, value: str):
145+
if len(value) > 45:
146+
raise ValueError("title must be 45 characters or fewer")
147+
if not isinstance(value, str):
148+
raise TypeError(f"expected title to be str, not {value.__class__.__name__}")
149+
self._title = value
150+
151+
@property
152+
def children(self) -> list[ModalItem]:
153+
"""The child components associated with the modal dialog."""
154+
return self._children
155+
156+
@children.setter
157+
def children(self, value: list[ModalItem]):
158+
for item in value:
159+
if not isinstance(item, (InputText, Item)):
160+
raise TypeError(
161+
"all Modal children must be InputText or Item, not"
162+
f" {item.__class__.__name__}"
163+
)
164+
self._weights = _ModalWeights(self._children)
165+
self._children = value
166+
167+
@property
168+
def custom_id(self) -> str:
169+
"""The ID of the modal dialog that gets received during an interaction."""
170+
return self._custom_id
171+
172+
@custom_id.setter
173+
def custom_id(self, value: str):
174+
if not isinstance(value, str):
175+
raise TypeError(
176+
f"expected custom_id to be str, not {value.__class__.__name__}"
177+
)
178+
if len(value) > 100:
179+
raise ValueError("custom_id must be 100 characters or fewer")
180+
self._custom_id = value
181+
182+
async def callback(self, interaction: Interaction):
183+
"""|coro|
184+
185+
The coroutine that is called when the modal dialog is submitted.
186+
Should be overridden to handle the values submitted by the user.
187+
188+
Parameters
189+
----------
190+
interaction: :class:`~discord.Interaction`
191+
The interaction that submitted the modal dialog.
192+
"""
193+
self.stop()
194+
195+
def to_components(self) -> list[dict[str, Any]]:
196+
def key(item: ModalItem) -> int:
197+
return item._rendered_row or 0
198+
199+
children = sorted(self._children, key=key)
200+
components: list[dict[str, Any]] = []
201+
for _, group in groupby(children, key=key):
202+
labels = False
203+
toplevel = False
204+
children = []
205+
for item in group:
206+
if item.uses_label() or isinstance(item, Select):
207+
labels = True
208+
elif isinstance(item, (TextDisplay,)):
209+
toplevel = True
210+
children.append(item)
211+
if not children:
212+
continue
213+
214+
if labels:
215+
for item in children:
216+
component = item.to_component_dict()
217+
label = component.pop("label", item.label)
218+
components.append(
219+
{
220+
"type": 18,
221+
"component": component,
222+
"label": label,
223+
"description": item.description,
224+
}
225+
)
226+
elif toplevel:
227+
components += [item.to_component_dict() for item in children]
228+
else:
229+
components.append(
230+
{
231+
"type": 1,
232+
"components": [item.to_component_dict() for item in children],
233+
}
234+
)
235+
236+
return components
237+
238+
def add_item(self, item: ModalItem) -> Self:
239+
"""Adds a component to the modal dialog.
240+
241+
Parameters
242+
----------
243+
item: Union[class:`InputText`, :class:`Item`]
244+
The item to add to the modal dialog
245+
"""
246+
247+
if len(self._children) > 5:
248+
raise ValueError("You can only have up to 5 items in a modal dialog.")
249+
250+
if not isinstance(item, (InputText, Item)):
251+
raise TypeError(f"expected InputText or Item, not {item.__class__!r}")
252+
if isinstance(item, (InputText, Select)) and not item.label:
253+
raise ValueError("InputTexts and Selects must have a label set")
254+
255+
self._weights.add_item(item)
256+
self._children.append(item)
257+
return self
258+
259+
def remove_item(self, item: ModalItem) -> Self:
260+
"""Removes a component from the modal dialog.
261+
262+
Parameters
263+
----------
264+
item: Union[class:`InputText`, :class:`Item`]
265+
The item to remove from the modal dialog.
266+
"""
267+
try:
268+
self._children.remove(item)
269+
except ValueError:
270+
pass
271+
return self
272+
273+
def get_item(self, id: str | int) -> ModalItem | None:
274+
"""Gets an item from the modal. Roughly equal to `utils.get(modal.children, ...)`.
275+
If an :class:`int` is provided, the item will be retrieved by ``id``, otherwise by ``custom_id``.
276+
277+
Parameters
278+
----------
279+
id: Union[:class:`int`, :class:`str`]
280+
The id or custom_id of the item to get
281+
282+
Returns
283+
-------
284+
Optional[Union[class:`InputText`, :class:`Item`]]
285+
The item with the matching ``custom_id`` or ``id`` if it exists.
286+
"""
287+
if not id:
288+
return None
289+
attr = "id" if isinstance(id, int) else "custom_id"
290+
return find(lambda i: getattr(i, attr, None) == id, self.children)
291+
292+
def stop(self) -> None:
293+
"""Stops listening to interaction events from the modal dialog."""
294+
if not self._stopped.done():
295+
self._stopped.set_result(True)
296+
self.__timeout_expiry = None
297+
if self.__timeout_task is not None:
298+
self.__timeout_task.cancel()
299+
self.__timeout_task = None
300+
301+
async def wait(self) -> bool:
302+
"""Waits for the modal dialog to be submitted."""
303+
return await self._stopped
304+
305+
def to_dict(self):
306+
return {
307+
"title": self.title,
308+
"custom_id": self.custom_id,
309+
"components": self.to_components(),
310+
}
311+
312+
async def on_error(self, error: Exception, interaction: Interaction) -> None:
313+
"""|coro|
314+
315+
A callback that is called when the modal's callback fails with an error.
316+
317+
The default implementation prints the traceback to stderr.
318+
319+
Parameters
320+
----------
321+
error: :class:`Exception`
322+
The exception that was raised.
323+
interaction: :class:`~discord.Interaction`
324+
The interaction that led to the failure.
325+
"""
326+
interaction.client.dispatch("modal_error", error, interaction)
327+
328+
async def on_timeout(self) -> None:
329+
"""|coro|
330+
331+
A callback that is called when a modal's timeout elapses without being explicitly stopped.
332+
"""
34333

35-
class Modal:
334+
class Modal(Modal):
36335
"""Represents a UI Modal dialog.
37336
38337
This object must be inherited to create a UI within Discord.

discord/ui/view.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -429,7 +429,6 @@ def stop(self) -> None:
429429
if self.__cancel_callback:
430430
self.__cancel_callback(self)
431431
self.__cancel_callback = None
432-
)
433432

434433
async def wait(self) -> bool:
435434
"""Waits until the view has finished interacting.

0 commit comments

Comments
 (0)