|
from __future__ import annotations |
|
from typing import ( |
|
Generic, |
|
Optional, |
|
TypeVar, |
|
Any, |
|
TYPE_CHECKING, |
|
) |
|
|
|
import discord |
|
|
|
if TYPE_CHECKING: |
|
from typing_extensions import Self |
|
|
|
SequenceT = TypeVar("SequenceT") |
|
Seq = list[SequenceT] | tuple[SequenceT, ...] |
|
Sequence = Seq[SequenceT | Seq[SequenceT]] |
|
|
|
PossibleMedia = ( |
|
discord.File |
|
| discord.Attachment |
|
| discord.MediaGalleryItem |
|
| discord.UnfurledMediaItem |
|
) |
|
PossiblePage = ( |
|
str |
|
| discord.ui.TextDisplay[Any] |
|
| discord.ui.Container[Any] |
|
| discord.ui.File[Any] |
|
| discord.ui.Section[Any] |
|
| discord.ui.MediaGallery[Any] |
|
| PossibleMedia |
|
) |
|
PageT_co = TypeVar( |
|
"PageT_co", |
|
bound=PossiblePage, |
|
covariant=True, |
|
) |
|
|
|
|
|
class ButtonPaginator(discord.ui.LayoutView, Generic[PageT_co]): |
|
"""A paginator that uses buttons to navigate through so called "pages". |
|
|
|
Parameters |
|
---------- |
|
pages: Sequence[PageT_co] |
|
A sequence of pages to paginate through. Each page can be a string, TextDisplay, |
|
Container, File, Section, or MediaGallery. Or a list/tuple of those. |
|
If a string is provided, it will be converted to a TextDisplay. |
|
|
|
Of course, you can also pass media such as discord.File or discord.Attachment. |
|
If you pass a discord.Attachment, it will be converted to a discord.File automatically. |
|
Other media types such as MediaGalleryItem and UnfurledMediaItem are also supported, |
|
but need further processsing in the `format_page` method, for obvious reasons. |
|
|
|
author_id: int | None |
|
The ID of the user who is allowed to interact with the paginator. If None, anyone can interact. |
|
Defaults to None. |
|
timeout: float | None |
|
The amount of time in seconds before the paginator times out. If None, the paginator will |
|
wait indefinitely for user interaction. Defaults to 180.0 seconds. |
|
delete_message_after: bool |
|
Whether to delete the message containing the paginator after the paginator times out or is stopped. |
|
Defaults to False. |
|
disable_items_after: bool |
|
Whether to disable all interactive items in the paginator after it times out or is stopped. |
|
Defaults to False. |
|
per_page: int |
|
The number of pages to display per page. Must be at least 1 and less than or equal to the total number of pages. Defaults to 1. |
|
container: discord.ui.Container[Any] | bool | None |
|
A Container to hold the pages. Can be one of: |
|
|
|
- A `discord.ui.Container` instance: The provided container will be used to hold the pages. |
|
- `True`: A new Container will be created automatically to hold the pages. |
|
- `None`: No Container will be used. |
|
Defaults to None. |
|
container_accent_color: discord.Color | int | None |
|
The accent color set for the created container. Only applicable if `container` is set to `True`. |
|
add_buttons_to_container: bool |
|
Whether to add the navigation buttons to the container if one is used. If False, the buttons |
|
will be added to the main view. Defaults to False. |
|
title: str | discord.ui.TextDisplay[Any] | None |
|
An optional title to display at the top of each page. Can be a string or a TextDisplay. If a string is provided, |
|
it will be converted to a TextDisplay. Defaults to None. |
|
description: str | discord.ui.TextDisplay[Any] | None |
|
An optional description to display below the title on each page. Can be a string or a TextDisplay. If a string is provided, |
|
it will be converted to a TextDisplay. Defaults to None. |
|
convert_str_to_text_display: bool |
|
Whether to convert strings in the `pages` parameter to TextDisplay instances. If False, strings will be used as-is. |
|
Defaults to False. |
|
""" |
|
|
|
message: discord.Message | None = None |
|
|
|
buttons_action_row: discord.ui.ActionRow[Self] = discord.ui.ActionRow(id=373) |
|
|
|
def __init__( |
|
self, |
|
pages: Sequence[PageT_co], |
|
*, |
|
author_id: int | None = None, |
|
timeout: float | None = 180.0, |
|
delete_message_after: bool = False, |
|
disable_items_after: bool = False, |
|
per_page: int = 1, |
|
container: discord.ui.Container[Any] | bool | None = None, |
|
container_accent_color: discord.Color | int | None = None, |
|
add_buttons_to_container: bool = False, |
|
title: str | discord.ui.TextDisplay[Any] | None = None, |
|
description: str | discord.ui.TextDisplay[Any] | None = None, |
|
convert_str_to_text_display: bool = False, |
|
) -> None: |
|
super().__init__(timeout=timeout) |
|
self.remove_item(self.buttons_action_row) |
|
self._add_buttons_to_container = add_buttons_to_container |
|
|
|
self.author_id: Optional[int] = author_id |
|
self.delete_message_after: bool = delete_message_after |
|
self.disable_items_after: bool = disable_items_after |
|
|
|
self.__convert_str_to_text_display = convert_str_to_text_display |
|
self._per_page = per_page |
|
self._pages = pages |
|
self._current_page_index = 0 |
|
|
|
self.per_page = self._per_page |
|
self.current_page_index = self._current_page_index |
|
self.pages = self._pages |
|
|
|
self._title: discord.ui.TextDisplay[Any] | None = ( |
|
discord.ui.TextDisplay[Any](str(title)) |
|
if title is not None and not isinstance(title, discord.ui.TextDisplay) |
|
else title if isinstance(title, discord.ui.TextDisplay) else None |
|
) |
|
self._description: discord.ui.TextDisplay[Any] | None = ( |
|
discord.ui.TextDisplay[Any](str(description)) |
|
if description and not isinstance(description, discord.ui.TextDisplay) |
|
else ( |
|
description if isinstance(description, discord.ui.TextDisplay) else None |
|
) |
|
) |
|
|
|
if container_accent_color and container is not True: |
|
raise ValueError( |
|
"container_accent_color may only be set if container is True. " |
|
"This means a new Container is created automatically with the given accent color." |
|
) |
|
|
|
self._container: discord.ui.Container[Any] | None = None |
|
if container is not None: |
|
if isinstance(container, discord.ui.Container): |
|
self._container = container |
|
elif container is True: |
|
self._container = discord.ui.Container( |
|
accent_color=container_accent_color, |
|
) |
|
else: |
|
raise TypeError( |
|
"Container must be a discord.ui.Container instance, True, or None." |
|
) |
|
|
|
self._buttons_container: discord.ui.Container[Any] | None = self._container |
|
|
|
self.__initial_container_items_count: int = ( |
|
len(self._container.children) if self._container else 0 |
|
) |
|
|
|
@property |
|
def current_pages(self) -> list[PageT_co]: |
|
return self.get_chunk(self.current_page_index) |
|
|
|
@property |
|
def max_pages(self) -> int: |
|
if self.per_page < 1: |
|
return 1 |
|
return (len(self.pages) + self.per_page - 1) // self.per_page |
|
|
|
@property |
|
def pages(self) -> Sequence[PageT_co]: |
|
return self._pages # |
|
|
|
@pages.setter |
|
def pages(self, value: Sequence[PageT_co]) -> None: |
|
if not value: |
|
raise ValueError("Pages cannot be empty.") |
|
|
|
if self.__convert_str_to_text_display: |
|
# fmt: off |
|
value = ( # pyright: ignore[reportUnknownVariableType, reportAssignmentType] |
|
[ |
|
discord.ui.TextDisplay(page) if isinstance(page, str) else page |
|
for page in value |
|
] |
|
) |
|
# fmt: on |
|
self._pages = value |
|
self._current_page_index = 0 |
|
|
|
@property |
|
def current_page_index(self) -> int: |
|
return self._current_page_index |
|
|
|
@current_page_index.setter |
|
def current_page_index(self, value: int) -> None: |
|
if value < 0: |
|
self._current_page_index = 0 |
|
elif value >= self.max_pages: |
|
self._current_page_index = self.max_pages - 1 |
|
else: |
|
self._current_page_index = value |
|
|
|
@property |
|
def per_page(self) -> int: |
|
return self._per_page |
|
|
|
@per_page.setter |
|
def per_page(self, value: int) -> None: |
|
if value < 1: |
|
raise ValueError("per_page must be at least 1.") |
|
elif value > len(self.pages): |
|
value = len(self.pages) |
|
|
|
self._per_page = value |
|
|
|
def stop(self) -> None: |
|
self.message = None |
|
super().stop() |
|
|
|
async def timeout_actions( |
|
self, interaction: discord.Interaction[Any] | None = None |
|
) -> None: |
|
"""Handle actions such as deleting the message or disabling items after timeout or stop. |
|
|
|
This is called by default in `on_timeout` and `on_stop`. |
|
|
|
Parameters |
|
---------- |
|
interaction: discord.Interaction[Any] | None |
|
The interaction to edit. If None, it will try to edit the set `message`. |
|
If no interaction is provided and no message is set, this function does nothing. |
|
Defaults to None. |
|
""" |
|
if not self.message and not interaction: |
|
return |
|
|
|
message: discord.Message | None = self.message |
|
interaction_message: discord.Message | None = ( |
|
interaction and interaction.message |
|
) |
|
|
|
if self.delete_message_after: |
|
if interaction: |
|
if not interaction.response.is_done(): |
|
await interaction.response.defer() |
|
|
|
try: |
|
await interaction.delete_original_response() |
|
except Exception: |
|
if interaction_message: |
|
await interaction_message.delete() |
|
elif message: |
|
await message.delete() |
|
|
|
elif message: |
|
await message.delete() |
|
return |
|
|
|
if self.disable_items_after: |
|
for child in self.walk_children(): |
|
if hasattr(child, "disabled"): |
|
child.disabled = True # pyright: ignore[reportAttributeAccessIssue] |
|
|
|
if interaction: |
|
if not interaction.response.is_done(): |
|
await interaction.response.edit_message(view=self) |
|
else: |
|
try: |
|
await interaction.edit_original_response(view=self) |
|
except Exception: |
|
if interaction_message: |
|
await interaction_message.edit(view=self) |
|
elif message: |
|
await message.edit(view=self) |
|
elif message: |
|
await message.edit(view=self) |
|
|
|
async def on_timeout( |
|
self, interaction: discord.Interaction[Any] | None = None |
|
) -> None: |
|
await self.timeout_actions(interaction) |
|
|
|
async def interaction_check(self, interaction: discord.Interaction[Any]) -> bool: |
|
if not self.author_id: |
|
return True |
|
|
|
if self.author_id != interaction.user.id: |
|
await interaction.response.send_message( |
|
"You cannot interact with this menu.", ephemeral=True |
|
) |
|
return False |
|
|
|
return True |
|
|
|
def get_chunk(self, page_number: int) -> list[PageT_co]: |
|
if page_number < 0 or page_number >= self.max_pages: |
|
self.current_page_index = 0 |
|
res = self.pages[self.current_page_index] |
|
if not isinstance(res, (list, tuple)): |
|
res = [res] |
|
|
|
return list(res) |
|
|
|
if self.per_page == 1: |
|
res = self.pages[page_number] |
|
else: |
|
base = page_number * self.per_page |
|
res = self.pages[base : base + self.per_page] |
|
|
|
if not isinstance(res, (list, tuple)): |
|
res = [res] |
|
|
|
return list(res) # pyright: ignore[reportReturnType] |
|
|
|
def format_page(self, chunk: Sequence[PageT_co]) -> Sequence[PageT_co]: |
|
return chunk |
|
|
|
async def get_page_kwargs( |
|
self, pages: Sequence[PageT_co], skip_formatting: bool = False |
|
) -> dict[str, Any]: |
|
if not pages: |
|
raise ValueError("No pages found.") |
|
|
|
formatted_pages: PageT_co | Sequence[PageT_co] |
|
if not skip_formatting: |
|
self.clear_items() |
|
if self._container: |
|
for _ in range( |
|
len(self._container.children) - self.__initial_container_items_count |
|
): |
|
self._container.remove_item(self._container.children[-1]) |
|
|
|
self._page_kwargs: dict[str, Any] = { |
|
"files": [], |
|
"view": self, |
|
} |
|
formatted_pages = await discord.utils.maybe_coroutine( |
|
self.format_page, pages |
|
) |
|
if self._title: |
|
if self._container: |
|
self._container.add_item(self._title) |
|
else: |
|
self.add_item(self._title) |
|
if self._description: |
|
if self._container: |
|
self._container.add_item(self._description) |
|
else: |
|
self.add_item(self._description) |
|
else: |
|
formatted_pages = pages |
|
|
|
if isinstance( |
|
formatted_pages, (tuple, list) |
|
): # pyright: ignore[reportUnnecessaryIsInstance] # no, it's not unnecessary |
|
for item in formatted_pages: |
|
await self.get_page_kwargs( |
|
item, skip_formatting=True # pyright: ignore[reportArgumentType] |
|
) |
|
|
|
if isinstance(formatted_pages, (discord.File, discord.Attachment)): |
|
if isinstance(formatted_pages, discord.Attachment): |
|
formatted_pages = ( |
|
await formatted_pages.to_file() |
|
) # pyright: ignore[reportAssignmentType] |
|
self._page_kwargs.setdefault("files", []).append(formatted_pages) |
|
|
|
if isinstance(formatted_pages, str): |
|
formatted_pages = discord.ui.TextDisplay( # pyright: ignore[reportUnknownVariableType, reportAssignmentType] |
|
formatted_pages |
|
) |
|
if isinstance(formatted_pages, discord.ui.Item): |
|
if self._container and not isinstance( |
|
formatted_pages, discord.ui.Container |
|
): |
|
self._container.add_item(formatted_pages) |
|
else: |
|
self.add_item(formatted_pages) |
|
|
|
if ( |
|
self._add_buttons_to_container |
|
and isinstance(formatted_pages, discord.ui.Container) |
|
and not self._buttons_container |
|
): |
|
self._buttons_container = formatted_pages |
|
|
|
return self._page_kwargs |
|
|
|
def update_buttons(self) -> None: |
|
self.previous_page.disabled = self.max_pages < 2 or self.current_page_index <= 0 |
|
self.next_page.disabled = ( |
|
self.max_pages < 2 or self.current_page_index >= self.max_pages - 1 |
|
) |
|
if self._container and self._container not in self.children: |
|
self.add_item(self._container) |
|
|
|
if self._add_buttons_to_container: |
|
buttons_target: discord.ui.Container[Any] | discord.ui.LayoutView | None = ( |
|
self._buttons_container or self._container |
|
) |
|
if not buttons_target: |
|
buttons_target = self._buttons_container = discord.ui.Container() |
|
self.add_item(buttons_target) |
|
else: |
|
buttons_target = self |
|
|
|
if ( |
|
isinstance(buttons_target, discord.ui.Container) |
|
and buttons_target not in self.children |
|
): |
|
self.add_item(buttons_target) |
|
|
|
if not buttons_target.find_item(373): |
|
buttons_target.add_item(self.buttons_action_row) |
|
|
|
async def update_page(self, interaction: discord.Interaction[Any]) -> None: |
|
if self.message is None: |
|
self.message = interaction.message |
|
|
|
kwargs = await self.get_page_kwargs(self.current_pages) # type: ignore |
|
self.update_buttons() |
|
self.reset_files(kwargs) |
|
kwargs["attachments"] = kwargs.pop("files", []) |
|
await interaction.response.edit_message(**kwargs) |
|
|
|
async def on_stop(self, interaction: discord.Interaction) -> Any: |
|
"""Method called when the stop button is used. |
|
|
|
This method exits to allow for custom behaviour when the stop button is pressed. |
|
|
|
By default, it does the following in order: |
|
|
|
1. If neither `disable_items_after` nor `delete_message_after` is set to `True`, it sends |
|
an ephemeral message saying "Stopped the paginator." |
|
2. Calls `timeout_actions` to manage post-stop actions like deleting the message or disabling items. |
|
3. Stops the paginator by calling `self.stop()`. |
|
|
|
Parameters |
|
---------- |
|
interaction: discord.Interaction |
|
The interaction that triggered the stop. |
|
""" |
|
if not any([self.disable_items_after, self.delete_message_after]): |
|
await interaction.response.send_message( |
|
"Stopped the paginator.", ephemeral=True |
|
) |
|
|
|
await self.timeout_actions(interaction) |
|
self.stop() |
|
|
|
@buttons_action_row.button( |
|
label="Previous", style=discord.ButtonStyle.blurple, emoji="⬅️" |
|
) |
|
async def previous_page( |
|
self, interaction: discord.Interaction[Any], _: discord.ui.Button[Self] |
|
) -> None: |
|
self.current_page_index -= 1 |
|
await self.update_page(interaction) |
|
|
|
@buttons_action_row.button( |
|
label="Next", style=discord.ButtonStyle.blurple, emoji="➡️" |
|
) |
|
async def next_page( |
|
self, interaction: discord.Interaction[Any], _: discord.ui.Button[Self] |
|
) -> None: |
|
self.current_page_index += 1 |
|
await self.update_page(interaction) |
|
|
|
@buttons_action_row.button(label="Stop", style=discord.ButtonStyle.red, emoji="⏹️") |
|
async def stop_paginator( |
|
self, interaction: discord.Interaction[Any], _: discord.ui.Button[Self] |
|
) -> None: |
|
await self.on_stop(interaction) |
|
|
|
def reset_files(self, page_kwargs: dict[str, Any]) -> None: |
|
files: list[discord.File] = page_kwargs.get("files", []) |
|
if not files: |
|
return |
|
|
|
for file in files: |
|
file.reset() |
|
|
|
async def start( |
|
self, obj: discord.Interaction | discord.abc.Messageable, **send_kwargs: Any |
|
) -> discord.Message: |
|
"""Starts the paginator by sending the initial message. |
|
|
|
Parameters |
|
---------- |
|
obj: discord.Interaction | discord.abc.Messageable |
|
The interaction or messageable (like a TextChannel) to send the paginator message to. |
|
send_kwargs: Any |
|
Additional keyword arguments to pass to the sending method. |
|
|
|
Returns |
|
------- |
|
discord.Message |
|
The message sent by the paginator. |
|
|
|
Raises |
|
------ |
|
TypeError |
|
If `obj` is neither an Interaction nor a Messageable. |
|
ValueError |
|
If there are no pages to display. |
|
""" |
|
kwargs = await self.get_page_kwargs(self.current_pages) # type: ignore |
|
self.update_buttons() |
|
if self.max_pages < 2: |
|
for child in self.buttons_action_row.walk_children(): |
|
if hasattr(child, "disabled"): |
|
child.disabled = True # pyright: ignore[reportAttributeAccessIssue] |
|
|
|
self.reset_files(kwargs) |
|
message: discord.Message | None = None |
|
if isinstance(obj, discord.Interaction): |
|
if obj.response.is_done(): |
|
message = await obj.followup.send(**kwargs, wait=True, **send_kwargs) |
|
else: |
|
response = await obj.response.send_message(**kwargs, **send_kwargs) |
|
if isinstance(response.resource, discord.InteractionMessage): |
|
message = response.resource |
|
else: |
|
message = await obj.original_response() |
|
|
|
elif isinstance( |
|
obj, discord.abc.Messageable |
|
): # pyright: ignore[reportUnnecessaryIsInstance] |
|
message = await obj.send(**kwargs, **send_kwargs) |
|
else: |
|
raise TypeError( |
|
f"Expected Interaction or Messageable, got {obj.__class__.__name__}" |
|
) |
|
|
|
self.message = message |
|
return message |
L 60:
self._auto_convert_string = auto_convert_stringneeds to be removed I believe.