Last active
February 8, 2024 04:22
-
-
Save trevorflahardy/622eb6acbe37cff391fa5fc9234bb32b to your computer and use it in GitHub Desktop.
A simple button paginator.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import discord | |
from discord import app_commands | |
# My custom bot subclass: | |
class MyBot(commands.Bot): | |
... | |
# Create a subclass of our paginator. It accepts two generic arguments, the type of entries | |
# passed to the format_page function and the bot subclass. The bot generic argument is optional. | |
class ThisPaginator(BaseButtonPaginator[int, MyBot]): | |
# The subclassed format page function that implements the logic of creating our embed. | |
# You can do this however you please, but in this example we'll add a field for each item. | |
async def format_page(self, entries: List[int], /) -> discord.Embed: | |
embed = discord.Embed(title='Welcome to my paginator!!') | |
for index, entry in enumerate(entries): | |
embed.add_field(name=str(index), value=entry) | |
embed.set_footer(text='Page {0.current_page}/{0.total_pages}'.format(self)) | |
return embed | |
# In a command, we need to define an instance of our view, create our first embed, | |
# and send it off to respond to the interaction. | |
@app_commands.command() | |
async def my_view_paginator(interaction: discord.Interaction[MyBot]) -> None: | |
# Create a new instance of our paginator. | |
view = ThisPaginator(entries=list(range(10)), target=interaction) | |
# Generate the first embed for the given page | |
embed = await view.embed() | |
await interaction.response.send_message(embed=embed, view=view) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
from __future__ import annotations | |
import abc | |
from typing import Generic, List, Optional, Union | |
from typing_extensions import Self, TypeAlias, TypeVar | |
import discord | |
from discord.ext import commands | |
T = TypeVar("T") | |
BotT = TypeVar("BotT", bound="commands.Bot", default="commands.Bot") | |
TargetType: TypeAlias = Union["discord.Interaction[BotT]", "commands.Context[BotT]"] | |
class BaseButtonPaginator(Generic[T, BotT], discord.ui.View, abc.ABC): | |
"""The base implementation of a button paginator. This class should be inherited | |
then the custom instance defined. | |
.. code-block:: python3 | |
import discord | |
from discord import app_commands | |
# My custom bot subclass: | |
class MyBot(commands.Bot): | |
... | |
class ThisPaginator(BaseButtonPaginator[int, MyBot]): | |
async def format_page(self, entries: List[int], /) -> discord.Embed: | |
embed = discord.Embed(title='Welcome to my paginator!!') | |
for index, entry in enumerate(entries): | |
embed.add_field(name=str(index), value=entry) | |
embed.set_footer(text='Page {0.current_page}/{0.total_pages}'.format(self)) | |
return embed | |
# And in a command as so: | |
@app_commands.command() | |
async def my_view_paginator(interaction: discord.Interaction[MyBot]) -> None: | |
# Create a new instance of our paginator. | |
view = ThisPaginator(entries=list(range(10)), target=interaction) | |
# Generate the first embed for the given page | |
embed = await view.embed() | |
await interaction.response.send_message(embed=embed, view=view) | |
Generics | |
-------- | |
T: The type of the entries to paginate. | |
BotT: The type of the bot subclass. Defaults to :class:`commands.Bot`. | |
Parameters | |
---------- | |
entries: List[T] | |
The entries to paginate. | |
per_page: int | |
The amount of entries to show per page. | |
clamp_pages: bool | |
Whether to clamp the pages to the max and min page. This means that when the user | |
reaches the max page, it will go back to the first page. Likewise, when the user | |
reaches the first page, it will go back to the last page. | |
target: Optional[Union[:class:`discord.Interaction`, :class:`commands.Context`]] | |
The target interaction or context to use for the paginator. This is used to | |
ensure that the user invoking the paginator is the same user that is interacting | |
with the paginator. | |
If this is ``None`` then the interaction check will always return True. | |
""" | |
def __init__( | |
self, | |
*, | |
entries: List[T], | |
per_page: int = 6, | |
clamp_pages: bool = True, | |
target: Optional[TargetType[BotT]] = None, | |
) -> None: | |
super().__init__(timeout=180) | |
self.entries: List[T] = entries | |
self.per_page: int = per_page | |
self.clamp_pages: bool = clamp_pages | |
self.target: Optional[TargetType[BotT]] = target | |
self.author: Optional[Union[discord.User, discord.Member]] = target and ( | |
target.user if isinstance(target, discord.Interaction) else target.author | |
) | |
self.bot: Optional[BotT] = target and ( | |
target.client if isinstance(target, discord.Interaction) else target.bot | |
) | |
self._current_page_index = 0 | |
self.pages = [ | |
entries[i : i + per_page] for i in range(0, len(entries), per_page) | |
] | |
@property | |
def max_page(self) -> int: | |
""":class:`int`: The max page count for this paginator.""" | |
return len(self.pages) | |
@property | |
def min_page(self) -> int: | |
""":class:`int`: The min page count for this paginator.""" | |
return 1 | |
@property | |
def current_page(self) -> int: | |
""":class:`int`: The current page the user is on.""" | |
return self._current_page_index + 1 | |
@property | |
def total_pages(self) -> int: | |
""":class:`int`: Returns the total amount of pages.""" | |
return len(self.pages) | |
@abc.abstractmethod | |
def format_page(self, entries: List[T], /) -> discord.Embed: | |
"""|maybecoro| | |
Used to make the embed that the user sees. This can be a coroutine or a regular | |
function. This must be overwritten by the subclass. | |
Parameters | |
---------- | |
entries: List[Any] | |
A list of entries for the current page. | |
Returns | |
------- | |
:class:`discord.Embed` | |
The embed for this page. | |
""" | |
raise NotImplementedError("Subclass did not overwrite format_page coro.") | |
async def embed(self) -> discord.Embed: | |
"""|coro| | |
A helper function to get the embed for the current page. | |
Returns | |
------- | |
:class:`discord.Embed` | |
The embed for the current page. | |
""" | |
return await discord.utils.maybe_coroutine( | |
self.format_page, self.pages[self._current_page_index] | |
) | |
async def interaction_check( | |
self, interaction: discord.Interaction[BotT], / | |
) -> Optional[bool]: | |
"""|coro| | |
The base interaction check for the given view. | |
This will always return ``True`` if the target is ``None``, otherwise it will check | |
that the user invoking the paginator is the same user that is interacting with the | |
paginator. | |
Parameters | |
---------- | |
interaction: :class:`discord.Interaction` | |
The interaction to check. | |
Returns | |
------- | |
Optional[:class:`bool`] | |
The result of the interaction check. If this returns ``None`` then the interaction | |
was responded to with an error message to the user. | |
""" | |
if self.target is None: | |
return True | |
# Prereq is that if self.target is not None then self.author should not be None. | |
assert self.author | |
# Ensure this is the correct invoker | |
if self.author.id != interaction.user.id: | |
return await interaction.response.send_message( | |
"Hey, this isn't yours!", ephemeral=True | |
) | |
# Ensure they invoke it in the correct channel. | |
if ( | |
self.target.channel | |
and interaction.channel | |
and self.target.channel.id != interaction.channel.id | |
): | |
return await interaction.response.send_message( | |
"Hey, this isn't in the right channel!", ephemeral=True | |
) | |
return True | |
def _clamp_page(self, count: int) -> None: | |
if count < 0 and self._current_page_index < 0: # Going down | |
self._current_page_index = self.max_page - 1 | |
elif count > 0 and self._current_page_index > self.max_page - 1: # Going up | |
self._current_page_index = 0 | |
def _switch_page(self, count: int) -> None: | |
self._current_page_index += count | |
if not self.clamp_pages: | |
return | |
self._clamp_page(count) | |
@discord.ui.button(emoji="\U000025c0", style=discord.ButtonStyle.blurple) | |
async def on_arrow_backward( | |
self, interaction: discord.Interaction[BotT], button: discord.ui.Button[Self] | |
) -> discord.InteractionMessage: | |
"""|coro| | |
The button to represent going backwards a page. | |
Parameters | |
---------- | |
interaction: :class:`discord.Interaction` | |
The interaction created from the user invoking the button. | |
button: :class:`discord.ui.Button` | |
The button that was pressed. | |
""" | |
await interaction.response.defer() | |
self._switch_page(-1) | |
embed = await self.embed() | |
return await interaction.edit_original_response(embed=embed) | |
@discord.ui.button(emoji="\U000025b6", style=discord.ButtonStyle.blurple) | |
async def on_arrow_forward( | |
self, interaction: discord.Interaction[BotT], button: discord.ui.Button[Self] | |
) -> discord.InteractionMessage: | |
"""|coro| | |
The button to represent going forward a page. | |
Parameters | |
---------- | |
interaction: :class:`discord.Interaction` | |
The interaction created from the user invoking the button. | |
button: :class:`discord.ui.Button` | |
The button that was pressed. | |
""" | |
await interaction.response.defer() | |
self._switch_page(1) | |
embed = await self.embed() | |
return await interaction.edit_original_response(embed=embed) | |
@discord.ui.button(emoji="\U000023f9", style=discord.ButtonStyle.blurple) | |
async def on_stop( | |
self, interaction: discord.Interaction[BotT], button: discord.ui.Button[Self] | |
) -> discord.InteractionMessage: | |
"""|coro| | |
The button to represent stopping the paginator. This will disable all children | |
to the view then edit the original message with the updated view. | |
This will also call :meth:`~discord.ui.View.stop` to stop the view. | |
Parameters | |
---------- | |
interaction: :class:`discord.Interaction` | |
The interaction created from the user invoking the button. | |
button: :class:`discord.ui.Button` | |
The button that was pressed. | |
""" | |
await interaction.response.defer() | |
for child in self.children: | |
child.disabled = True # type: ignore | |
self.stop() | |
return await interaction.edit_original_response(view=self) |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Hi there! I'm looking to use this code in an open source bot. Is there a license for its use?
Thanks!