discordcn 0.0.1a1__py3-none-any.whl → 0.0.1a3__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.
- discordcn/__init__.py +1 -4
- discordcn/_utils/__init__.py +3 -0
- discordcn/_utils/dependencies.py +194 -0
- discordcn/_version.py +2 -2
- discordcn/pycord/__init__.py +28 -0
- discordcn/pycord/_utils/__init__.py +8 -0
- discordcn/pycord/_utils/_asyncio.py +34 -0
- discordcn/pycord/interfaces/__init__.py +23 -0
- discordcn/pycord/interfaces/_types.py +14 -0
- discordcn/pycord/interfaces/accordion.py +497 -0
- discordcn/pycord/interfaces/confirm.py +195 -0
- discordcn/pycord/interfaces/paginator.py +286 -0
- discordcn-0.0.1a3.dist-info/METADATA +93 -0
- discordcn-0.0.1a3.dist-info/RECORD +17 -0
- discordcn-0.0.1a1.dist-info/METADATA +0 -23
- discordcn-0.0.1a1.dist-info/RECORD +0 -7
- {discordcn-0.0.1a1.dist-info → discordcn-0.0.1a3.dist-info}/WHEEL +0 -0
- {discordcn-0.0.1a1.dist-info → discordcn-0.0.1a3.dist-info}/licenses/LICENSE +0 -0
|
@@ -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()
|