Skip to content

Commit 3d04a71

Browse files
krittickVincentRPSLulalaby
authored
Add ext.menus pagination module (#539)
* add back ext.menus * almost a complete rework - needs more tweaking * minor formatting changes * only show first/last buttons when not on a page directly adjacent to the first/last page * add ability to append items from a custom view to the pagination view * more formatting changes, change unnecessary usage of MISSING to None * add optional page indicator * fix weird refactor recommendation, thanks sourcery * initial attempt to add a customize_button method * fix bug when custom views aren't specified * make page indicator counts use a more human-friendly format (i.e. +1) * change main class name, add some comments, etc * add goto_page method * more comments * add support for sending paginated messages from interactions * whoops * add basic example * add additional example for custom view support * consistent quote usage * Revert "consistent quote usage" This reverts commit b474686. * Documenting ext.menus * Fixing Menus Docs * Fix * docs updates * more docstring updates * more docstring updates * make return types for send/respond more consistent * Apply suggestions from code review Co-authored-by: RPS <[email protected]> * more docstrings, add attributes block for Paginator class * more docstrings * fix note * start work on adding timeout handling to paginator * timeout work * update return values, add message attribute * update return values, add message attribute * update return values, add message attribute * add disable_on_timeout parameter * add changes to message returns in respond() as well * revert inadvertent change to setup.py * [wip] partial fix for slash groups example (#574) * potential fix for second part of slash groups example * Revert "potential fix for second part of slash groups example" This reverts commit 1f60568. * fix typing Co-authored-by: Vincent <[email protected]> Co-authored-by: Lala Sabathil <[email protected]> Co-authored-by: RPS <[email protected]>
1 parent 8161ab9 commit 3d04a71

File tree

7 files changed

+516
-2
lines changed

7 files changed

+516
-2
lines changed

discord/ext/menus/__init__.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
"""
2+
discord.ext.menus
3+
~~~~~~~~~~~~~~~~~~~~~
4+
An extension module to provide useful menu options.
5+
6+
:copyright: 2021-present Pycord-Development
7+
:license: MIT, see LICENSE for more details.
8+
"""
9+
10+
from .pagination import *

discord/ext/menus/pagination.py

Lines changed: 375 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,375 @@
1+
"""
2+
The MIT License (MIT)
3+
4+
Copyright (c) 2021-present Pycord Development
5+
6+
Permission is hereby granted, free of charge, to any person obtaining a
7+
copy of this software and associated documentation files (the "Software"),
8+
to deal in the Software without restriction, including without limitation
9+
the rights to use, copy, modify, merge, publish, distribute, sublicense,
10+
and/or sell copies of the Software, and to permit persons to whom the
11+
Software is furnished to do so, subject to the following conditions:
12+
13+
The above copyright notice and this permission notice shall be included in
14+
all copies or substantial portions of the Software.
15+
16+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
17+
OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
18+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
19+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
20+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
21+
FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
22+
DEALINGS IN THE SOFTWARE.
23+
"""
24+
from typing import Dict, List, Optional, Union
25+
26+
import discord
27+
from discord import abc
28+
from discord.commands import ApplicationContext
29+
from discord.ext.commands import Context
30+
31+
32+
class PaginatorButton(discord.ui.Button):
33+
"""Creates a button used to navigate the paginator.
34+
35+
Parameters
36+
----------
37+
38+
button_type: :class:`str`
39+
The type of button being created.
40+
Must be one of ``first``, ``prev``, ``next``, or ``last``.
41+
paginator: :class:`Paginator`
42+
The Paginator class where this button will be used
43+
"""
44+
45+
def __init__(self, label, emoji, style, disabled, button_type, paginator):
46+
super().__init__(label=label, emoji=emoji, style=style, disabled=disabled, row=0)
47+
self.label = label
48+
self.emoji = emoji
49+
self.style = style
50+
self.disabled = disabled
51+
self.button_type = button_type
52+
self.paginator = paginator
53+
54+
async def callback(self, interaction: discord.Interaction):
55+
if self.button_type == "first":
56+
self.paginator.current_page = 0
57+
elif self.button_type == "prev":
58+
self.paginator.current_page -= 1
59+
elif self.button_type == "next":
60+
self.paginator.current_page += 1
61+
elif self.button_type == "last":
62+
self.paginator.current_page = self.paginator.page_count
63+
await self.paginator.goto_page(interaction=interaction, page_number=self.paginator.current_page)
64+
65+
66+
class Paginator(discord.ui.View):
67+
"""Creates a paginator which can be sent as a message and uses buttons for navigation.
68+
69+
Attributes
70+
----------
71+
current_page: :class:`int`
72+
Zero-indexed value showing the current page number
73+
page_count: :class:`int`
74+
Zero-indexed value showing the total number of pages
75+
buttons: Dict[:class:`str`, Dict[:class:`str`, Union[:class:`~PaginatorButton`, :class:`bool`]]]
76+
Dictionary containing the :class:`~PaginatorButton` objects included in this Paginator
77+
user: Optional[Union[:class:`~discord.User`, :class:`~discord.Member`]]
78+
The user or member that invoked the Paginator.
79+
message: Union[:class:`~discord.Message`, :class:`~discord.WebhookMessage`]
80+
The message sent from the Paginator.
81+
82+
Parameters
83+
----------
84+
pages: Union[List[:class:`str`], List[:class:`discord.Embed`]]
85+
Your list of strings or embeds to paginate
86+
show_disabled: :class:`bool`
87+
Choose whether or not to show disabled buttons
88+
show_indicator: :class:`bool`
89+
Choose whether to show the page indicator
90+
author_check: :class:`bool`
91+
Choose whether or not only the original user of the command can change pages
92+
disable_on_timeout: :class:`bool`
93+
Should the buttons be disabled when the pagintator view times out?
94+
custom_view: Optional[:class:`discord.ui.View`]
95+
A custom view whose items are appended below the pagination buttons
96+
"""
97+
98+
def __init__(
99+
self,
100+
pages: Union[List[str], List[discord.Embed]],
101+
show_disabled=True,
102+
show_indicator=True,
103+
author_check=True,
104+
disable_on_timeout=True,
105+
custom_view: Optional[discord.ui.View] = None,
106+
timeout: Optional[float] = 180.0,
107+
):
108+
super().__init__(timeout=timeout)
109+
self.timeout = timeout
110+
self.pages = pages
111+
self.current_page = 0
112+
self.page_count = len(self.pages) - 1
113+
self.show_disabled = show_disabled
114+
self.show_indicator = show_indicator
115+
self.disable_on_timeout = disable_on_timeout
116+
self.custom_view = custom_view
117+
self.message: Union[discord.Message, discord.WebhookMessage]
118+
self.buttons = {
119+
"first": {
120+
"object": PaginatorButton(
121+
label="<<",
122+
style=discord.ButtonStyle.blurple,
123+
emoji=None,
124+
disabled=True,
125+
button_type="first",
126+
paginator=self,
127+
),
128+
"hidden": True,
129+
},
130+
"prev": {
131+
"object": PaginatorButton(
132+
label="<",
133+
style=discord.ButtonStyle.red,
134+
emoji=None,
135+
disabled=True,
136+
button_type="prev",
137+
paginator=self,
138+
),
139+
"hidden": True,
140+
},
141+
"page_indicator": {
142+
"object": discord.ui.Button(
143+
label=f"{self.current_page + 1}/{self.page_count + 1}",
144+
style=discord.ButtonStyle.gray,
145+
disabled=True,
146+
row=0,
147+
),
148+
"hidden": False,
149+
},
150+
"next": {
151+
"object": PaginatorButton(
152+
label=">",
153+
style=discord.ButtonStyle.green,
154+
emoji=None,
155+
disabled=True,
156+
button_type="next",
157+
paginator=self,
158+
),
159+
"hidden": True,
160+
},
161+
"last": {
162+
"object": PaginatorButton(
163+
label=">>",
164+
style=discord.ButtonStyle.blurple,
165+
emoji=None,
166+
disabled=True,
167+
button_type="last",
168+
paginator=self,
169+
),
170+
"hidden": True,
171+
},
172+
}
173+
self.update_buttons()
174+
175+
self.usercheck = author_check
176+
self.user = None
177+
178+
async def on_timeout(self) -> None:
179+
"""Disables all buttons when the view times out."""
180+
if self.disable_on_timeout:
181+
for item in self.children:
182+
item.disabled = True
183+
await self.message.edit(view=self)
184+
185+
async def goto_page(self, interaction: discord.Interaction, page_number=0):
186+
"""Updates the interaction response message to show the specified page number.
187+
188+
Parameters
189+
----------
190+
interaction: :class:`discord.Interaction`
191+
The interaction which called the Paginator
192+
page_number: :class:`int`
193+
The page to display.
194+
195+
.. note::
196+
197+
Page numbers are zero-indexed when referenced internally, but appear as one-indexed when shown to the user.
198+
199+
Returns
200+
---------
201+
:class:`~Paginator`
202+
The Paginator class
203+
"""
204+
self.update_buttons()
205+
page = self.pages[page_number]
206+
await interaction.response.edit_message(
207+
content=page if isinstance(page, str) else None, embed=page if isinstance(page, discord.Embed) else None, view=self
208+
)
209+
210+
async def interaction_check(self, interaction):
211+
if self.usercheck:
212+
return self.user == interaction.user
213+
return True
214+
215+
def customize_button(
216+
self, button_name: str = None, button_label: str = None, button_emoji=None, button_style: discord.ButtonStyle = discord.ButtonStyle.gray
217+
) -> Union[PaginatorButton, bool]:
218+
"""Allows you to easily customize the various pagination buttons.
219+
220+
Parameters
221+
----------
222+
button_name: :class:`str`
223+
Name of the button to customize
224+
button_label: :class:`str`
225+
Label to display on the button
226+
button_emoji:
227+
Emoji to display on the button
228+
button_style: :class:`~discord.ButtonStyle`
229+
ButtonStyle to use for the button
230+
231+
Returns
232+
-------
233+
:class:`~PaginatorButton`
234+
The button that was customized
235+
"""
236+
237+
if button_name not in self.buttons.keys():
238+
return False
239+
button: PaginatorButton = self.buttons[button_name]["object"]
240+
button.label = button_label
241+
button.emoji = button_emoji
242+
button.style = button_style
243+
return button
244+
245+
def update_buttons(self) -> Dict:
246+
"""Updates the display state of the buttons (disabled/hidden)
247+
248+
Returns
249+
-------
250+
Dict[:class:`str`, Dict[:class:`str`, Union[:class:`~PaginatorButton`, :class:`bool`]]]
251+
The dictionary of buttons that was updated.
252+
"""
253+
for key, button in self.buttons.items():
254+
if key == "first":
255+
if self.current_page <= 1:
256+
button["hidden"] = True
257+
elif self.current_page >= 1:
258+
button["hidden"] = False
259+
elif key == "last":
260+
if self.current_page >= self.page_count - 1:
261+
button["hidden"] = True
262+
if self.current_page < self.page_count - 1:
263+
button["hidden"] = False
264+
elif key == "next":
265+
if self.current_page == self.page_count:
266+
button["hidden"] = True
267+
elif self.current_page < self.page_count:
268+
button["hidden"] = False
269+
elif key == "prev":
270+
if self.current_page <= 0:
271+
button["hidden"] = True
272+
elif self.current_page >= 0:
273+
button["hidden"] = False
274+
self.clear_items()
275+
if self.show_indicator:
276+
self.buttons["page_indicator"]["object"].label = f"{self.current_page + 1}/{self.page_count + 1}"
277+
for key, button in self.buttons.items():
278+
if key != "page_indicator":
279+
if button["hidden"]:
280+
button["object"].disabled = True
281+
if self.show_disabled:
282+
self.add_item(button["object"])
283+
else:
284+
button["object"].disabled = False
285+
self.add_item(button["object"])
286+
elif self.show_indicator:
287+
self.add_item(button["object"])
288+
289+
# We're done adding standard buttons, so we can now add any specified custom view items below them
290+
# The bot developer should handle row assignments for their view before passing it to Paginator
291+
if self.custom_view:
292+
for item in self.custom_view.children:
293+
self.add_item(item)
294+
295+
return self.buttons
296+
297+
async def send(self, messageable: abc.Messageable, ephemeral: bool = False):
298+
"""Sends a message with the paginated items.
299+
300+
301+
Parameters
302+
------------
303+
messageable: :class:`discord.abc.Messageable`
304+
The messageable channel to send to.
305+
ephemeral: :class:`bool`
306+
Choose whether the message is ephemeral or not. Only works with slash commands.
307+
308+
Returns
309+
--------
310+
Union[:class:`~discord.Message`, :class:`~discord.WebhookMessage`]
311+
The message that was sent with the Paginator.
312+
"""
313+
314+
if not isinstance(messageable, abc.Messageable):
315+
raise TypeError("messageable should be a subclass of abc.Messageable")
316+
317+
page = self.pages[0]
318+
319+
if isinstance(messageable, (ApplicationContext, Context)):
320+
self.user = messageable.author
321+
322+
if isinstance(messageable, ApplicationContext):
323+
msg = await messageable.respond(
324+
content=page if isinstance(page, str) else None,
325+
embed=page if isinstance(page, discord.Embed) else None,
326+
view=self,
327+
ephemeral=ephemeral,
328+
)
329+
330+
else:
331+
msg = await messageable.send(
332+
content=page if isinstance(page, str) else None,
333+
embed=page if isinstance(page, discord.Embed) else None,
334+
view=self,
335+
)
336+
if isinstance(msg, (discord.WebhookMessage, discord.Message)):
337+
self.message = msg
338+
elif isinstance(msg, discord.Interaction):
339+
self.message = await msg.original_message()
340+
341+
return self.message
342+
343+
async def respond(self, interaction: discord.Interaction, ephemeral: bool = False):
344+
"""Sends an interaction response or followup with the paginated items.
345+
346+
347+
Parameters
348+
------------
349+
interaction: :class:`discord.Interaction`
350+
The interaction associated with this response.
351+
ephemeral: :class:`bool`
352+
Choose whether the message is ephemeral or not.
353+
354+
Returns
355+
--------
356+
:class:`~discord.Interaction`
357+
The message sent with the paginator
358+
"""
359+
page = self.pages[0]
360+
self.user = interaction.user
361+
362+
if interaction.response.is_done():
363+
msg = await interaction.followup.send(
364+
content=page if isinstance(page, str) else None, embed=page if isinstance(page, discord.Embed) else None, view=self, ephemeral=ephemeral
365+
)
366+
367+
else:
368+
msg = await interaction.response.send_message(
369+
content=page if isinstance(page, str) else None, embed=page if isinstance(page, discord.Embed) else None, view=self, ephemeral=ephemeral
370+
)
371+
if isinstance(msg, (discord.WebhookMessage, discord.Message)):
372+
self.message = msg
373+
elif isinstance(msg, discord.Interaction):
374+
self.message = await msg.original_message()
375+
return self.message

docs/conf.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -163,6 +163,7 @@
163163
'discord_extensions': [
164164
('discord.ext.commands', 'ext/commands'),
165165
('discord.ext.tasks', 'ext/tasks'),
166+
('discord.ext.menus', 'ext/menus'),
166167
],
167168
}
168169

0 commit comments

Comments
 (0)