discordcn 0.0.1a2__py3-none-any.whl → 0.0.1a4__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,497 @@
1
+ """Accordion interface module.
2
+
3
+ This module provides :class:`~discordcn.pycord.ui.AccordionInterface`, a
4
+ :class:`discord.ui.DesignerView` implementation that renders an "accordion"
5
+ layout using Discord's UI building blocks (text displays, sections, optional
6
+ container wrapping, and accessory buttons).
7
+
8
+ Exactly one section is expanded at a time:
9
+ - The active section shows its description and has a disabled accessory button
10
+ using the "down" emoji.
11
+ - All other sections show only their header and have an enabled accessory
12
+ button using the "right" emoji.
13
+
14
+ The view supports both non-persistent and persistent configurations:
15
+ - Non-persistent: configured by passing a non-``None`` ``timeout`` and leaving
16
+ ``custom_id`` as ``None``.
17
+ - Persistent: configured by passing ``timeout=None`` and a non-empty
18
+ ``custom_id``. In this mode, each section accessory button receives a
19
+ deterministic ``custom_id`` derived from section content, position, and the
20
+ view's ``custom_id``. Interactions rebuild a fresh view snapshot reflecting
21
+ the newly expanded section.
22
+
23
+ The view can optionally wrap content in a :class:`discord.ui.Container`. When
24
+ container wrapping is disabled, the view receives the text display and sections
25
+ as direct items.
26
+
27
+ Notes:
28
+ - This interface edits the original message view on each selection via
29
+ ``interaction.edit(view=...)``.
30
+ - In persistent mode, interactions do not mutate the original view instance;
31
+ they construct a new :class:`discord.ui.DesignerView` containing the updated
32
+ items.
33
+ """
34
+
35
+ from __future__ import annotations
36
+
37
+ import hashlib
38
+ from functools import partial
39
+ from typing import TYPE_CHECKING, Any, Literal, NamedTuple, cast, overload
40
+
41
+ import discord
42
+ from typing_extensions import Self, override
43
+
44
+ from discordcn.pycord._utils import maybe_awaitable
45
+
46
+ if TYPE_CHECKING:
47
+ from ._types import Callback, EmojiType
48
+
49
+ BLUE = discord.Color.blue()
50
+ """Default color used for the container if not overridden."""
51
+
52
+ ARROW_DOWN = discord.PartialEmoji(name="⬇️")
53
+ """Default emoji used for the active (expanded) section accessory."""
54
+
55
+ ARROW_RIGHT = discord.PartialEmoji(name="➡️")
56
+ """Default emoji used for inactive (collapsed) section accessories."""
57
+
58
+
59
+ class _SectionMetadata(NamedTuple):
60
+ """Metadata for a single accordion section.
61
+
62
+ Attributes:
63
+ header: The section title shown in the section text.
64
+ description: The section body displayed only when the section is active.
65
+ """
66
+
67
+ header: str
68
+ description: str
69
+
70
+
71
+ class _SectionStore(NamedTuple):
72
+ """Internal store linking a rendered UI section to its metadata.
73
+
74
+ Attributes:
75
+ section: The UI section object that is inserted into the view.
76
+ metadata: The logical content (header/description) for that section.
77
+ """
78
+
79
+ section: discord.ui.Section[AccordionInterface]
80
+ metadata: _SectionMetadata
81
+
82
+
83
+ class _SectionButton(discord.ui.Button["AccordionInterface"]):
84
+ """Accessory button used to expand a section.
85
+
86
+ This button delegates to a callback and supports both sync and async
87
+ callables via :func:`discordcn.pycord._utils.maybe_awaitable`.
88
+
89
+ In non-persistent mode, the callback typically mutates the current view
90
+ instance and edits the message using ``view=self``.
91
+
92
+ In persistent mode, the callback typically builds a new view snapshot and
93
+ edits the message using that snapshot.
94
+
95
+ Args:
96
+ emoji: The emoji shown on the accessory button.
97
+ callback: A callable invoked with the interaction when the button is
98
+ pressed. This may be sync or async.
99
+ disabled: Whether the button is initially disabled.
100
+ custom_id: Optional custom ID used for persistent components. If set,
101
+ Discord can route interactions to this button across restarts.
102
+
103
+ Attributes:
104
+ _callback: Stored callback invoked when the button is pressed.
105
+ """
106
+
107
+ def __init__(
108
+ self,
109
+ *,
110
+ emoji: EmojiType,
111
+ callback: Callback[Any],
112
+ disabled: bool,
113
+ custom_id: str | None,
114
+ ) -> None:
115
+ super().__init__(
116
+ emoji=emoji,
117
+ style=discord.ButtonStyle.secondary,
118
+ disabled=disabled,
119
+ custom_id=custom_id,
120
+ )
121
+ self._callback: Callback[Any] = callback
122
+
123
+ @override
124
+ async def callback(self, interaction: discord.Interaction) -> None:
125
+ """Handle a button press.
126
+
127
+ Args:
128
+ interaction: The interaction generated by the button press.
129
+
130
+ Returns:
131
+ None.
132
+ """
133
+ await maybe_awaitable(self._callback(interaction))
134
+
135
+
136
+ class AccordionInterface(discord.ui.DesignerView):
137
+ """Interactive accordion view built from Discord UI primitives.
138
+
139
+ The interface displays a header/description at the top and a list of
140
+ collapsible sections underneath. Exactly one section is expanded at a time.
141
+
142
+ The view can operate in two modes:
143
+ - Non-persistent: ``timeout`` is an integer and ``custom_id`` must be
144
+ ``None``.
145
+ - Persistent: ``timeout`` is ``None`` and ``custom_id`` must be provided.
146
+ Section accessory buttons will be created with deterministic ``custom_id``
147
+ values derived from the section content and index plus the view's
148
+ ``custom_id``. Interactions rebuild a view snapshot rather than mutating
149
+ the original instance.
150
+
151
+ Attributes:
152
+ header: Title displayed at the top of the view.
153
+ description: Description displayed under the title.
154
+ custom_id: Persistent identifier for the view. This is ``None`` for
155
+ non-persistent instances.
156
+ color: Accent color used for container rendering and persistent rebuilds.
157
+ _index: Index of the currently expanded section.
158
+ _down_emoji: Emoji used for the expanded section's accessory button.
159
+ _right_emoji: Emoji used for collapsed sections' accessory buttons.
160
+ _container: Whether the view content is wrapped in a container.
161
+ _sections: Internal list of stored sections (UI + metadata).
162
+ """
163
+
164
+ @overload
165
+ def __init__(
166
+ self,
167
+ *args: tuple[str, str],
168
+ header: str,
169
+ description: str,
170
+ custom_id: None = ...,
171
+ color: discord.Color = ...,
172
+ timeout: int = ...,
173
+ down_emoji: EmojiType = ...,
174
+ right_emoji: EmojiType = ...,
175
+ container: bool = True,
176
+ user_id: int | None = ...,
177
+ auto_fold: bool = ...,
178
+ ) -> None: ...
179
+
180
+ @overload
181
+ def __init__(
182
+ self,
183
+ *args: tuple[str, str],
184
+ header: str,
185
+ description: str,
186
+ custom_id: str,
187
+ color: discord.Color = ...,
188
+ timeout: None = ...,
189
+ down_emoji: EmojiType = ...,
190
+ right_emoji: EmojiType = ...,
191
+ container: bool = True,
192
+ user_id: None = ...,
193
+ auto_fold: Literal[True] = True,
194
+ ) -> None: ...
195
+
196
+ @override
197
+ def __init__(
198
+ self,
199
+ *args: tuple[str, str],
200
+ header: str,
201
+ description: str,
202
+ color: discord.Color = BLUE,
203
+ custom_id: str | None = None,
204
+ timeout: int | None = 900,
205
+ down_emoji: EmojiType = ARROW_DOWN,
206
+ right_emoji: EmojiType = ARROW_RIGHT,
207
+ container: bool = True,
208
+ user_id: int | None = None,
209
+ auto_fold: bool = True,
210
+ ) -> None:
211
+ """Initialize an accordion interface.
212
+
213
+ The view requires a consistent persistent configuration:
214
+ - For non-persistent usage, set ``timeout`` to an integer and leave
215
+ ``custom_id`` as ``None``.
216
+ - For persistent usage, set ``timeout=None`` and provide a non-empty
217
+ ``custom_id``. You also cannot provide a ``user_id`` in this case.
218
+
219
+ Args:
220
+ *args: Initial section tuples as ``(header, description)``. The first
221
+ section (index 0) is expanded by default.
222
+ header: Title text displayed at the top of the view.
223
+ description: Description text displayed below the title.
224
+ color: Accent color used when rendering the optional container and
225
+ when rebuilding persistent snapshots.
226
+ custom_id: Persistent identifier for the view. Must be provided when
227
+ ``timeout=None`` and must be ``None`` otherwise.
228
+ timeout: Timeout in seconds before the view times out. Set to
229
+ ``None`` to create a persistent view.
230
+ down_emoji: Emoji used for the active (expanded) section accessory.
231
+ right_emoji: Emoji used for inactive (collapsed) section accessories.
232
+ container: Whether to wrap content in a :class:`discord.ui.Container`.
233
+ user_id: If provided, only interactions from this user are accepted.
234
+ Interactions from other users are ignored.
235
+ auto_fold: Whether to automatically fold sections when a new section is unfolded.
236
+ Defaults to ``True`` for persistent views and ``False`` for non-persistent views.
237
+
238
+ Raises:
239
+ TypeError: If ``timeout`` and ``custom_id`` do not match one of the
240
+ supported configurations:
241
+ - ``timeout is None`` requires ``custom_id`` to be set.
242
+ - ``timeout is not None`` requires ``custom_id`` to be ``None``.
243
+ """
244
+ if (custom_id is None and timeout is None) or (custom_id is not None and timeout is not None):
245
+ msg = "custom_id must be set when timeout is None and vice versa"
246
+ raise TypeError(msg)
247
+
248
+ _args: list[_SectionMetadata] = [_SectionMetadata(*arg) for arg in args]
249
+
250
+ self.header: str = header
251
+ self.description: str = description
252
+
253
+ self.custom_id: str | None = custom_id
254
+ self.color: discord.Color = color
255
+ self.user_id: int | None = user_id
256
+ self._index: int = 0
257
+ self._down_emoji: EmojiType = down_emoji
258
+ self._right_emoji: EmojiType = right_emoji
259
+ self._container: bool = container
260
+ self._sections: list[_SectionStore] = [
261
+ self._construct_section_store(metadata, i) for i, metadata in enumerate(_args)
262
+ ]
263
+ if self._persistent and not auto_fold:
264
+ self.auto_fold: bool = True
265
+ else:
266
+ self.auto_fold = auto_fold
267
+
268
+ items: tuple[discord.ui.ViewItem[AccordionInterface], ...] = ( # pyright: ignore[reportUnknownVariableType]
269
+ discord.ui.TextDisplay(f"## {header}\n{description}"),
270
+ *(_section.section for _section in self._sections),
271
+ )
272
+ if self._container:
273
+ items = (discord.ui.Container(*items, color=color),)
274
+
275
+ super().__init__(
276
+ *items, # pyright: ignore[reportUnknownArgumentType]
277
+ timeout=timeout,
278
+ disable_on_timeout=True,
279
+ )
280
+
281
+ @property
282
+ def _persistent(self) -> bool:
283
+ """Whether this view should behave as persistent.
284
+
285
+ Returns:
286
+ ``True`` if the view is persistent, ``False`` otherwise.
287
+ """
288
+ return self.custom_id is not None
289
+
290
+ def _construct_section_store(
291
+ self,
292
+ metadata: _SectionMetadata,
293
+ i: int,
294
+ current_idx: int | None = None,
295
+ ) -> _SectionStore:
296
+ """Construct a rendered section and its store wrapper.
297
+
298
+ The section is created expanded if ``i`` matches the resolved current
299
+ index and collapsed otherwise.
300
+
301
+ In persistent mode, the accessory button is assigned a deterministic
302
+ ``custom_id`` derived from the section header/description, its index, and
303
+ the view's ``custom_id``.
304
+
305
+ Args:
306
+ metadata: Section content (header and description).
307
+ i: Section index within the view.
308
+ current_idx: Index that should be expanded for the constructed
309
+ section set. If ``None``, defaults to the view's current index.
310
+
311
+ Returns:
312
+ A :class:`_SectionStore` linking the UI section and metadata.
313
+ """
314
+ resolved_current_idx = self._index if current_idx is None else current_idx
315
+ expanded = i == resolved_current_idx
316
+
317
+ button_custom_id: str | None = None
318
+ if self._persistent:
319
+ digest_input = f"{metadata.header}{metadata.description}{i}{self.custom_id}"
320
+ button_custom_id = hashlib.sha256(digest_input.encode()).hexdigest()
321
+
322
+ return _SectionStore(
323
+ section=discord.ui.Section(
324
+ discord.ui.TextDisplay(
325
+ self._format_section(
326
+ metadata.header,
327
+ metadata.description if expanded else "",
328
+ )
329
+ ),
330
+ accessory=_SectionButton(
331
+ emoji=self._down_emoji if expanded else self._right_emoji,
332
+ callback=partial(self._persistent_callback, i=i)
333
+ if self._persistent
334
+ else partial(self._callback, i=i),
335
+ disabled=expanded,
336
+ custom_id=button_custom_id,
337
+ ),
338
+ ),
339
+ metadata=metadata,
340
+ )
341
+
342
+ @staticmethod
343
+ def _format_section(header: str, description: str) -> str:
344
+ """Format a section into Markdown for a :class:`discord.ui.TextDisplay`.
345
+
346
+ Args:
347
+ header: Section title text.
348
+ description: Section body text. Use an empty string to represent a
349
+ collapsed section.
350
+
351
+ Returns:
352
+ A Markdown string combining header and description.
353
+ """
354
+ return f"### {header}\n{description}".strip()
355
+
356
+ def _unfold_section(self, section: _SectionStore) -> None:
357
+ """Expand a section in-place.
358
+
359
+ Args:
360
+ section: The section store to expand.
361
+
362
+ Returns:
363
+ None.
364
+ """
365
+ cast("discord.ui.TextDisplay[Self, Any]", section.section.items[0]).content = self._format_section(
366
+ section.metadata.header,
367
+ section.metadata.description,
368
+ )
369
+ button = cast("_SectionButton", section.section.accessory)
370
+ button.emoji = self._down_emoji
371
+ button.disabled = True
372
+
373
+ def _fold_section(self, section: _SectionStore) -> None:
374
+ """Collapse a section in-place.
375
+
376
+ Args:
377
+ section: The section store to collapse.
378
+
379
+ Returns:
380
+ None.
381
+ """
382
+ cast("discord.ui.TextDisplay[Self, Any]", section.section.items[0]).content = self._format_section(
383
+ section.metadata.header,
384
+ "",
385
+ )
386
+ button = cast("_SectionButton", section.section.accessory)
387
+ button.emoji = self._right_emoji
388
+ button.disabled = False
389
+
390
+ async def _callback(self, interaction: discord.Interaction, i: int) -> None:
391
+ """Handle selection of a section (non-persistent mode).
392
+
393
+ Args:
394
+ interaction: The interaction that triggered the selection.
395
+ i: Index of the section to expand.
396
+
397
+ Returns:
398
+ None.
399
+ """
400
+ if interaction.user and interaction.user.id != self.user_id:
401
+ return
402
+ if self.auto_fold:
403
+ self._fold_section(self._sections[self._index])
404
+ self._unfold_section(self._sections[i])
405
+ self._index = i
406
+
407
+ await interaction.edit(view=self) # pyright: ignore[reportUnknownMemberType]
408
+
409
+ async def _persistent_callback(self, interaction: discord.Interaction, i: int) -> None:
410
+ """Handle selection of a section (persistent mode).
411
+
412
+ Args:
413
+ interaction: The interaction that triggered the selection.
414
+ i: Index of the section to expand in the rebuilt snapshot.
415
+
416
+ Returns:
417
+ None.
418
+ """
419
+ items: tuple[discord.ui.ViewItem[AccordionInterface], ...] = tuple(
420
+ self._construct_section_store(
421
+ metadata=section_store.metadata,
422
+ i=section_store_i,
423
+ current_idx=i,
424
+ ).section
425
+ for section_store_i, section_store in enumerate(self._sections)
426
+ )
427
+
428
+ if self._container:
429
+ items = (discord.ui.Container(*items, color=self.color),)
430
+
431
+ await interaction.edit( # pyright: ignore[reportUnknownMemberType]
432
+ view=discord.ui.DesignerView(
433
+ *items,
434
+ )
435
+ )
436
+
437
+ def add_section(self, header: str, description: str) -> int:
438
+ """Add a new section to the accordion.
439
+
440
+ Args:
441
+ header: Section title.
442
+ description: Section body.
443
+
444
+ Returns:
445
+ The index of the newly added section.
446
+ """
447
+ metadata = _SectionMetadata(header, description)
448
+ self._sections.append(self._construct_section_store(metadata, len(self._sections)))
449
+ return len(self._sections) - 1
450
+
451
+ def remove_section(self, i: int) -> None:
452
+ """Remove a section from the accordion.
453
+
454
+ Args:
455
+ i: Index of the section to remove.
456
+
457
+ Returns:
458
+ None.
459
+ """
460
+ if self._index == i:
461
+ self._index -= 1
462
+ del self._sections[i]
463
+
464
+ def insert_section(self, i: int, header: str, description: str) -> None:
465
+ """Insert a new section at a specific index.
466
+
467
+ Args:
468
+ i: Index to insert at.
469
+ header: Section title.
470
+ description: Section body.
471
+
472
+ Returns:
473
+ None.
474
+ """
475
+ metadata = _SectionMetadata(header, description)
476
+ self._sections.insert(i, self._construct_section_store(metadata, i))
477
+
478
+ async def finish(self) -> None:
479
+ """Finalize the view by disabling components and stopping the view.
480
+
481
+ Returns:
482
+ None.
483
+ """
484
+ await super().on_timeout()
485
+ self.stop()
486
+
487
+ @override
488
+ async def on_timeout(self) -> None:
489
+ """Handle view timeout.
490
+
491
+ Returns:
492
+ None.
493
+ """
494
+ await self.finish()
495
+
496
+
497
+ __all__ = ("AccordionInterface",)
@@ -0,0 +1,195 @@
1
+ """Confirmation interface module."""
2
+
3
+ from asyncio import Future, get_running_loop
4
+ from typing import Literal
5
+
6
+ import discord
7
+ from typing_extensions import Self, override
8
+
9
+ from discordcn.pycord._utils import maybe_awaitable, safe_set_future_exception, safe_set_future_result
10
+
11
+ from ._types import Callback
12
+
13
+
14
+ class ConfirmInterface(discord.ui.DesignerView):
15
+ """Interactive confirmation UI with confirm/cancel buttons.
16
+
17
+ This view presents a message with confirm and cancel buttons and exposes the
18
+ user's decision via an awaitable Future. Multiple coroutines may await the
19
+ result using `wait()`.
20
+
21
+ Result semantics:
22
+ - True: The confirm button was pressed.
23
+ - False: The cancel button was pressed.
24
+ - TimeoutError: The view timed out before a decision was made.
25
+
26
+ The interaction can optionally be restricted to a single user, and custom
27
+ callbacks may be provided for confirm and cancel actions.
28
+
29
+ Notes:
30
+ - The underlying Future is completed exactly once.
31
+ - On timeout, the Future is completed with a TimeoutError.
32
+ - The user is responsible for handling the
33
+ interaction response.
34
+
35
+ Attributes:
36
+ header (str): The title text displayed at the top of the confirmation
37
+ message.
38
+ description (str): The main body text describing the action to be
39
+ confirmed.
40
+ footer (str | None): Optional footer text displayed below the main
41
+ content.
42
+ variant (Literal["default", "danger"]): Visual style of the interface.
43
+ The ``"danger"`` variant highlights destructive actions.
44
+ user_id (int | None): If set, only interactions from this user are
45
+ accepted.
46
+ yes_label (str): Label used for the confirm button.
47
+ no_label (str): Label used for the cancel button.
48
+ emojis (tuple[str | None, str | None]): Emojis used for the cancel and
49
+ confirm buttons, respectively. Values may be ``None`` if emojis are
50
+ disabled.
51
+ cancel_callback (Callback[discord.Interaction] | None): Callback
52
+ function to handle cancel button interactions.
53
+ confirm_callback (Callback[discord.Interaction] | None): Callback
54
+ function to handle confirm button interactions.
55
+ """
56
+
57
+ @override
58
+ def __init__(
59
+ self,
60
+ header: str,
61
+ description: str,
62
+ *,
63
+ footer: str | None = None,
64
+ variant: Literal["default", "danger"] = "default",
65
+ emojis: bool | tuple[str, str] = False,
66
+ timeout: int = 900,
67
+ user_id: int | None = None,
68
+ yes_label: str = "Confirm",
69
+ no_label: str = "Cancel",
70
+ cancel_callback: Callback[discord.Interaction] | None = None,
71
+ confirm_callback: Callback[discord.Interaction] | None = None,
72
+ ) -> None:
73
+ """Initialize a confirmation interface.
74
+
75
+ Args:
76
+ header: The main title displayed at the top of the confirmation message.
77
+ description: The body text explaining the action to be confirmed.
78
+ footer: Optional footer text displayed in a muted style.
79
+ variant: Visual style of the interface. Use ``"danger"`` for destructive
80
+ actions and ``"default"`` for neutral confirmations.
81
+ emojis: Whether to display emojis on the buttons.
82
+ - ``False``: No emojis.
83
+ - ``True``: Use default ❌ / ✅ emojis.
84
+ - ``(cancel, confirm)``: Custom emoji strings.
85
+ timeout: Time in seconds before the interface times out. Defaults to 900 seconds.
86
+ user_id: If provided, only interactions from this user are accepted.
87
+ Interactions from other users are ignored.
88
+ yes_label: Label for the confirm button.
89
+ no_label: Label for the cancel button.
90
+ cancel_callback: Callback function to handle cancel button interactions.
91
+ confirm_callback: Callback function to handle confirm button interactions.
92
+ """
93
+ self.header: str = header
94
+ self.description: str = description
95
+ self.footer: str | None = footer
96
+ self.variant: Literal["default", "danger"] = variant
97
+ self.user_id: int | None = user_id
98
+ self.emojis: tuple[str | None, str | None] = (
99
+ emojis if isinstance(emojis, tuple) else ("❌", "✅") if emojis else (None, None)
100
+ )
101
+ self.yes_label: str = yes_label
102
+ self.no_label: str = no_label
103
+ self._userset_cancel_callback: Callback[discord.Interaction] | None = cancel_callback
104
+ self._userset_confirm_callback: Callback[discord.Interaction] | None = confirm_callback
105
+
106
+ self._cancel_button: discord.ui.Button[Self] = discord.ui.Button(
107
+ label=no_label,
108
+ style=discord.ButtonStyle.secondary,
109
+ emoji=self.emojis[0],
110
+ )
111
+ self._cancel_button.callback = self._cancel_btn_callback
112
+
113
+ self._confirm_button: discord.ui.Button[Self] = discord.ui.Button(
114
+ label=yes_label,
115
+ style=discord.ButtonStyle.danger if variant == "danger" else discord.ButtonStyle.success,
116
+ emoji=self.emojis[1],
117
+ )
118
+ self._confirm_button.callback = self._confirm_btn_callback
119
+
120
+ texts: list[str] = [f"### {header}\n\n{description}"]
121
+ if footer is not None:
122
+ texts.append(f"-# {footer}")
123
+
124
+ self._future: Future[tuple[bool, discord.Interaction]] = get_running_loop().create_future()
125
+
126
+ super().__init__(
127
+ discord.ui.Container( # pyright: ignore[reportUnknownArgumentType]
128
+ *[
129
+ discord.ui.TextDisplay(
130
+ text,
131
+ )
132
+ for text in texts
133
+ ],
134
+ color=discord.Color.red() if variant == "danger" else discord.Color.green(),
135
+ ),
136
+ discord.ui.ActionRow( # pyright: ignore[reportUnknownArgumentType]
137
+ self._cancel_button,
138
+ self._confirm_button,
139
+ ),
140
+ timeout=timeout,
141
+ disable_on_timeout=True, # This is an opinionated decision
142
+ )
143
+
144
+ async def _confirm_btn_callback(self, interaction: discord.Interaction) -> None:
145
+ if not interaction.user:
146
+ msg = "Interaction user is None."
147
+ raise TypeError(msg)
148
+
149
+ if self.user_id is not None and interaction.user.id != self.user_id:
150
+ return
151
+
152
+ if self._userset_confirm_callback is not None:
153
+ await maybe_awaitable(self._userset_confirm_callback(interaction))
154
+
155
+ safe_set_future_result(self._future, value=(True, interaction))
156
+
157
+ async def _cancel_btn_callback(self, interaction: discord.Interaction) -> None:
158
+ if not interaction.user:
159
+ msg = "Interaction user is None."
160
+ raise TypeError(msg)
161
+
162
+ if self.user_id is not None and interaction.user.id != self.user_id:
163
+ return
164
+
165
+ if self._userset_cancel_callback is not None:
166
+ await maybe_awaitable(self._userset_cancel_callback(interaction))
167
+
168
+ safe_set_future_result(self._future, value=(False, interaction))
169
+
170
+ @override
171
+ async def wait(self) -> tuple[bool, discord.Interaction]: # pyright: ignore[reportIncompatibleMethodOverride]
172
+ """Wait for the user to confirm or cancel the action.
173
+
174
+ This coroutine blocks until the user interacts with the confirmation
175
+ interface or the underlying Future is completed.
176
+
177
+ Returns:
178
+ Tuple containing:
179
+ - True if the confirm button was pressed, False if the cancel button was pressed.
180
+ - The interaction object that triggered the result.
181
+
182
+ Raises:
183
+ TimeoutError: If the interface times out before a user decision is made.
184
+ """
185
+ return await self._future
186
+
187
+ async def finish(self) -> None:
188
+ """Calls :meth:`stop` and awaits the result of :meth:`wait`."""
189
+ await super().on_timeout()
190
+ self.stop()
191
+
192
+ @override
193
+ async def on_timeout(self) -> None:
194
+ safe_set_future_exception(self._future, TimeoutError("Timed out waiting for confirmation."))
195
+ await self.finish()