diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..b150884 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,15 @@ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "dpy_paginator" +version = "0.1.0" +description = "Button-navigated paginator for discord.py — config-free, injectable emojis, installable." +requires-python = ">=3.10" +dependencies = [ + "discord.py>=2.0", +] + +[tool.hatch.build.targets.wheel] +packages = ["src/dpy_paginator"] diff --git a/src/dpy_paginator/__init__.py b/src/dpy_paginator/__init__.py new file mode 100644 index 0000000..2fbddc2 --- /dev/null +++ b/src/dpy_paginator/__init__.py @@ -0,0 +1,3 @@ +from .dpy_paginator import ButtonPaginator, JumpToPageModal, DEFAULT_EMOJIS, Page + +__all__ = ["ButtonPaginator", "JumpToPageModal", "DEFAULT_EMOJIS", "Page"] diff --git a/src/dpy_paginator/dpy_paginator.py b/src/dpy_paginator/dpy_paginator.py new file mode 100644 index 0000000..294ed0c --- /dev/null +++ b/src/dpy_paginator/dpy_paginator.py @@ -0,0 +1,383 @@ +""" +button paginator for discord.py + +a discord.ui.View that paginates mixed content (strings, embeds, files, +attachments, or dicts of mixed content with custom buttons) behind +previous / jump / next navigation, with an optional cache button. + + from dpy_paginator import ButtonPaginator + + pages = [discord.Embed(title=f"Page {i}") for i in range(5)] + await ButtonPaginator(pages, author_id=ctx.author.id).start(ctx) + +emojis: navigation uses plain unicode by default (no setup). pass emojis= to +override with custom application/guild emojis the bot can use: + + ButtonPaginator(pages, emojis={ + "previous": "<:icon_back:123...>", + "next": "<:icon_next:123...>", + "cache": "<:icon_cache:123...>", + }) + +page types: a page may be a str, a discord.Embed, a discord.File/Attachment, +a sequence of those, or a dict. a dict page can carry 'content'/'embed(s)'/ +'file(s)' plus a 'buttons' list of custom button configs (see README). + +config-free: no host config import; everything is passed at construction. +""" + +from __future__ import annotations + +import asyncio +from typing import ( + Any, + Dict, + Generic, + List, + Optional, + Sequence, + Tuple, + TYPE_CHECKING, + TypeVar, + Union, +) + +import discord +from discord.abc import Messageable + +if TYPE_CHECKING: + from typing_extensions import Self + + Interaction = discord.Interaction[Any] + +Page = Union[ + str, + Sequence[str], + discord.Embed, + Sequence[discord.Embed], + discord.File, + Sequence[discord.File], + discord.Attachment, + Sequence[discord.Attachment], + Dict[str, Any], + Tuple[str, discord.Embed], +] + +DEFAULT_EMOJIS = { + "previous": "\u25c0\ufe0f", # ◀️ + "next": "\u25b6\ufe0f", # ▶️ + "cache": "\U0001f5c2\ufe0f", # 🗂️ +} + +PageT_co = TypeVar("PageT_co", bound=Page, covariant=True) + + +class _CustomButton(discord.ui.Button): + """a page-supplied button carrying arbitrary data and an optional callback""" + + def __init__(self, label, style, emoji, disabled, data, callback_func, paginator): + super().__init__(label=label, style=style, emoji=emoji, disabled=disabled) + self.data = data + self.callback_func = callback_func + self.paginator = paginator + + async def callback(self, interaction): + """invoke the supplied callback, or echo the button's data""" + if self.callback_func: + await self.callback_func(interaction, self, self.data, self.paginator) + else: + await interaction.response.send_message( + f"Button pressed with data: {self.data}", ephemeral=True + ) + + +class JumpToPageModal(discord.ui.Modal, title="Jump to Page"): + """modal that lets a user jump to a specific page""" + + def __init__(self, paginator: "ButtonPaginator"): + super().__init__() + self.paginator = paginator + self.page_number = discord.ui.TextInput( + label="Enter page number", + style=discord.TextStyle.short, + placeholder="e.g., 1", + required=True, + ) + self.add_item(self.page_number) + + async def on_submit(self, interaction: discord.Interaction) -> None: + """validate the entered page number and update the paginator""" + try: + page = int(self.page_number.value) + except ValueError: + await interaction.response.send_message("Please enter a valid number.", ephemeral=True) + return + + if 1 <= page <= self.paginator.max_pages: + self.paginator.current_page = page - 1 + await self.paginator.update_page(interaction) + else: + await interaction.response.send_message( + f"Page number must be between 1 and {self.paginator.max_pages}.", ephemeral=True + ) + + +class ButtonPaginator(Generic[PageT_co], discord.ui.View): + """button-navigated paginator supporting mixed page content and custom buttons""" + + message: Optional[Union[discord.Message, discord.WebhookMessage]] = None + + def __init__( + self, + pages: Sequence[PageT_co], + cache: Optional[List[str]] = None, + *, + author_id: Optional[int] = None, + timeout: Optional[float] = 180.0, + delete_message_after: bool = False, + per_page: int = 1, + mentions_allowed: Optional[discord.AllowedMentions] = None, + ephemeral: bool = False, + page_text: str = "Page {} of {}", + emojis: Optional[dict] = None, + cache_sleep: float = 1.0, + ) -> None: + """build a paginator + + args: + pages: sequence of page content to paginate + cache: optional per-page strings of user mentions ("<@id> <@id> ...") + to prime the viewer's client mention cache; enables the cache button + author_id: restrict interaction to this user id if set + timeout: seconds before the view stops + delete_message_after: delete the message on timeout + per_page: how many entries render per page + mentions_allowed: AllowedMentions for sends (defaults to all) + ephemeral: send/edit ephemerally + page_text: format string for the jump button label + emojis: override navigation emojis (keys: previous/next/cache) + cache_sleep: seconds to wait after priming before refreshing the view + """ + super().__init__(timeout=timeout) + self.author_id: Optional[int] = author_id + self.delete_message_after: bool = delete_message_after + self.mentions_allowed = mentions_allowed or discord.AllowedMentions.all() + self.ephemeral: bool = ephemeral + self.current_page: int = 0 + self.per_page: int = per_page + self.pages: Any = pages + self.cache: Any = cache + self.cache_sleep: float = cache_sleep + self.page_text = page_text + self.emojis = {**DEFAULT_EMOJIS, **(emojis or {})} + self.current_page_buttons: List[discord.ui.Button] = [] + + total_pages, left_over = divmod(len(self.pages), self.per_page) + self.max_pages: int = total_pages + (1 if left_over else 0) + self._page_kwargs: Dict[str, Any] = self._fresh_kwargs() + + def _fresh_kwargs(self) -> Dict[str, Any]: + """a clean page-kwargs dict""" + return { + "content": None, + "embeds": [], + "files": [], + "view": self, + "allowed_mentions": self.mentions_allowed, + } + + def stop(self) -> None: + """stop the view and drop the message reference""" + self.message = None + super().stop() + + async def interaction_check(self, interaction: Interaction) -> bool: + """restrict interaction to author_id when set""" + if not self.author_id or self.author_id == interaction.user.id: + return True + await interaction.response.send_message("You cannot interact with this menu.", ephemeral=True) + return False + + def get_page(self, page_number: int) -> Union[PageT_co, Sequence[PageT_co]]: + """return the content for a page index, wrapping out-of-range to 0""" + if page_number < 0 or page_number >= self.max_pages: + self.current_page = 0 + return self.pages[self.current_page] + if self.per_page == 1: + return self.pages[page_number] + base = page_number * self.per_page + return self.pages[base: base + self.per_page] + + def format_page( + self, page: Union[PageT_co, Sequence[PageT_co]] + ) -> Union[PageT_co, Sequence[PageT_co]]: + """hook to transform a page before rendering; override in a subclass""" + return page + + async def get_page_kwargs( + self, page: Union[PageT_co, Sequence[PageT_co]], skip_formatting: bool = False + ) -> Dict[str, Any]: + """build the send/edit kwargs for a page, extracting any custom buttons""" + if not skip_formatting: + self._page_kwargs = self._fresh_kwargs() + formatted_page = await discord.utils.maybe_coroutine(self.format_page, page) + else: + formatted_page = page + + self.current_page_buttons = [] + + if isinstance(formatted_page, dict): + formatted_page = dict(formatted_page) + for config in formatted_page.pop("buttons", []): + self.current_page_buttons.append( + _CustomButton( + label=config.get("label", "Button"), + style=config.get("style", discord.ButtonStyle.gray), + emoji=config.get("emoji"), + disabled=config.get("disabled", False), + data=config.get("data"), + callback_func=config.get("callback"), + paginator=self, + ) + ) + + for key, value in formatted_page.items(): + if key == "embeds" and isinstance(value, list): + self._page_kwargs["embeds"].extend(value) + elif key == "embed" and isinstance(value, discord.Embed): + self._page_kwargs["embeds"].append(value) + elif key == "files" and isinstance(value, list): + self._page_kwargs["files"].extend(value) + elif key == "file" and isinstance(value, (discord.File, discord.Attachment)): + if isinstance(value, discord.Attachment): + value = await value.to_file() + self._page_kwargs["files"].append(value) + else: + self._page_kwargs[key] = value + elif isinstance(formatted_page, str): + content = self._page_kwargs["content"] + self._page_kwargs["content"] = ( + formatted_page if content is None else f"{content}\n{formatted_page}" + ) + elif isinstance(formatted_page, discord.Embed): + self._page_kwargs["embeds"].append(formatted_page) + elif isinstance(formatted_page, (discord.File, discord.Attachment)): + if isinstance(formatted_page, discord.Attachment): + formatted_page = await formatted_page.to_file() + self._page_kwargs["files"].append(formatted_page) + elif isinstance(formatted_page, (tuple, list)): + for item in formatted_page: + await self.get_page_kwargs(item, skip_formatting=True) + else: + raise TypeError("page content must be str, discord.Embed, file/attachment, sequence, or dict") + + return self._page_kwargs + + def update_buttons(self) -> None: + """rebuild the action row for the current page state""" + self.clear_items() + + self.previous_page.emoji = self.emojis["previous"] + self.previous_page.disabled = self.current_page <= 0 + self.add_item(self.previous_page) + + self.jump_button.label = self.page_text.format(self.current_page + 1, self.max_pages) + self.add_item(self.jump_button) + + for button in self.current_page_buttons: + self.add_item(button) + + if self.cache: + self.cache_button.emoji = self.emojis["cache"] + self.add_item(self.cache_button) + + self.next_page.emoji = self.emojis["next"] + self.next_page.disabled = self.current_page >= self.max_pages - 1 + self.add_item(self.next_page) + + async def _build_render_kwargs(self) -> Dict[str, Any]: + """build the edit-ready kwargs for the current page (buttons + attachments)""" + kwargs = await self.get_page_kwargs(self.get_page(self.current_page)) + self.update_buttons() + self.reset_files(kwargs) + kwargs["attachments"] = kwargs.pop("files", []) + return kwargs + + async def update_page(self, interaction: Interaction) -> None: + """render the current page in response to a navigation interaction""" + kwargs = await self._build_render_kwargs() + if not self.ephemeral and self.message is None: + self.message = interaction.message + await interaction.response.edit_message(**kwargs) + + @discord.ui.button(style=discord.ButtonStyle.blurple) + async def previous_page(self, interaction: Interaction, _: discord.ui.Button[Self]) -> None: + """go to the previous page""" + self.current_page -= 1 + await self.update_page(interaction) + + @discord.ui.button(label="Page 1 of X", style=discord.ButtonStyle.gray) + async def jump_button(self, interaction: discord.Interaction, _: discord.ui.Button) -> None: + """open the jump-to-page modal""" + await interaction.response.send_modal(JumpToPageModal(self)) + + @discord.ui.button(style=discord.ButtonStyle.gray) + async def cache_button(self, interaction: discord.Interaction, _: discord.ui.Button) -> None: + """prime the viewer's client mention cache, then refresh the current view + + discord clients render <@id> as a raw id until the user object is cached. + this posts the page's mentions in a throwaway ephemeral message so the client + resolves them, waits cache_sleep seconds, then re-edits the message so the + mentions display as names — no manual page-flip needed. + + allowed_mentions is none() on purpose: the <@id> tags still render (which is + what primes the cache) but no actual ping/notification fires. + """ + await interaction.response.send_message( + content=self.cache[self.current_page], + ephemeral=True, + delete_after=1, + allowed_mentions=discord.AllowedMentions.none(), + ) + await asyncio.sleep(self.cache_sleep) + kwargs = await self._build_render_kwargs() + if self.message: + await self.message.edit(**kwargs) + + @discord.ui.button(style=discord.ButtonStyle.blurple) + async def next_page(self, interaction: Interaction, _: discord.ui.Button[Self]) -> None: + """go to the next page""" + self.current_page += 1 + await self.update_page(interaction) + + def reset_files(self, page_kwargs: Dict[str, Any]) -> None: + """rewind file pointers so they can be sent again""" + for file in page_kwargs.get("files", []): + file.reset() + + async def start( + self, obj: Union[Interaction, Messageable], **send_kwargs: Any + ) -> Optional[Union[discord.Message, discord.WebhookMessage]]: + """send the first page; obj is an Interaction or a Messageable""" + kwargs = await self.get_page_kwargs(self.get_page(self.current_page)) + self.update_buttons() + + if self.max_pages < 2: + self.stop() + kwargs.pop("view", None) + + self.reset_files(kwargs) + send_kwargs["ephemeral"] = self.ephemeral + + if isinstance(obj, discord.Interaction): + if obj.response.is_done(): + self.message = await obj.followup.send(**kwargs, **send_kwargs) + else: + await obj.response.send_message(**kwargs, **send_kwargs) + self.message = await obj.original_response() + elif isinstance(obj, Messageable): + self.message = await obj.send(**kwargs, **send_kwargs) + else: + raise TypeError(f"expected Interaction or Messageable, got {obj.__class__.__name__}") + + return self.message