Source code for lightbulb.utils.pag

# -*- coding: utf-8 -*-
# Copyright © tandemdude 2020-present
#
# This file is part of Lightbulb.
#
# Lightbulb is free software: you can redistribute it and/or modify
# it under the terms of the GNU Lesser General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# Lightbulb is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
# GNU Lesser General Public License for more details.
#
# You should have received a copy of the GNU Lesser General Public License
# along with Lightbulb. If not, see <https://www.gnu.org/licenses/>.
from __future__ import annotations

__all__ = ["StringPaginator", "EmbedPaginator", "Paginator"]

import abc
import io
import textwrap
import typing as t

import hikari

T = t.TypeVar("T")


[docs] class Paginator(abc.ABC, t.Generic[T]): __slots__ = ( "_max_total_chars", "_max_total_lines", "_max_content_chars", "_max_content_lines", "_line_separator", "_page_prefix", "_page_suffix", "_next_page", "_pages", "_page_factory", "current_page", ) @abc.abstractmethod def __init__( self, *, max_lines: t.Optional[int] = None, max_chars: int = 2000, prefix: str = "", suffix: str = "", line_separator: str = "\n", page_factory: t.Callable[[int, str], T] = lambda i, s: s, # type: ignore ) -> None: self._page_prefix: str = prefix self._page_suffix: str = suffix # Dummy minimum content size. This is never used, but is just symbolic for readability. min_content = f"A{line_separator}" # at least one line of content. extra_lines = prefix.count(line_separator) + suffix.count(line_separator) min_total_lines = min_content.count(line_separator) + extra_lines if max_lines is not None: if max_lines < min_total_lines: raise ValueError(f"This configuration requires at least {min_total_lines} lines per page!") # At least 1 character per page, or we recurse forever! prefix_len = len(prefix) + len(suffix) min_total_len = prefix_len + len(min_content) if max_chars < min_total_len: raise ValueError(f"This configuration requires at least {min_total_len} characters per page.") self._max_total_chars = max_chars self._max_total_lines = max_lines if max_lines is not None else float("inf") self._max_content_chars = max_chars - prefix_len self._max_content_lines = max_lines - extra_lines if max_lines is not None else float("inf") self._line_separator = line_separator self._next_page: io.StringIO = io.StringIO() self._pages: t.List[str] = [] self._page_factory = page_factory self.current_page: int = 0 def __len__(self) -> int: return len(self._pages)
[docs] def build_pages(self, page_number_start: int = 1) -> t.Iterator[T]: """ The current pages that have been created. Args: page_number_start (:obj:`int`): The page number to start at. Defaults to ``1``. Returns: Iterator[ T ]: Lazy generator of each page. """ # Only add the last page if it is not empty. if self._next_page.tell(): last_page = self._next_page.getvalue() if len(last_page) > 0: self.new_page() for i, page in enumerate(self._pages, start=page_number_start): yield self._page_factory(i, page)
[docs] def add_line(self, line: t.Any) -> None: """ Add a line to the paginator. Args: line (Any): The line to add to the paginator. Will be converted to a :obj:`str`. Returns: ``None`` """ whole_text = str(line).replace("\t", (" " * 4)).replace("\r", "").split(self._line_separator) for line in whole_text: self._add_one_line(line)
def _add_one_line(self, line: str) -> None: existing_chars, existing_lines = self._sizes() remaining_chars = self._max_content_chars - existing_chars remaining_lines = self._max_content_lines - existing_lines this_char_count = len(line) if not self._next_page.tell(): self._next_page.write(self._page_prefix) if this_char_count > self._max_content_chars: self._chunk_add(line) return if remaining_chars < this_char_count or remaining_lines <= 0: self.new_page() self._add_one_line(line) return self._next_page.write(line) self._next_page.write(self._line_separator) def _chunk_add(self, line: str) -> None: # Try to split up words, if not, break mid-word. wrapper = textwrap.TextWrapper( width=self._max_content_chars, expand_tabs=True, tabsize=4, max_lines=self._max_content_lines, # type: ignore ) lines = wrapper.wrap(line) for line in lines: if len(line) > self._max_content_chars - len(self._line_separator): for i in range(0, len(line), self._max_content_chars): next_line = line[i : i + self._max_content_chars] self.add_line(next_line) else: self.add_line(line)
[docs] def new_page(self) -> None: """ Start a new page. Returns: ``None`` """ # Remove final newline if it is there. next_page = self._next_page.getvalue() if next_page.endswith(self._line_separator): next_page = next_page[: -len(self._line_separator)] next_page += self._page_suffix # Append page self._pages.append(next_page) # Clear buffer self._next_page.seek(0, 0) self._next_page.truncate(0)
def _sizes(self) -> t.Tuple[int, int]: page = self._next_page.getvalue()[len(self._page_prefix) :] current_chars = len(page) current_lines = page.count(self._line_separator) return current_chars, current_lines
[docs] class StringPaginator(Paginator[str]): """ Creates pages from lines of text according to the given parameters. Text should be added to the paginator using :meth:`~.utils.pag.StringPaginator.add_line`, which will then be split up into an appropriate number of pages, accessible through :attr:`~.utils.pag.StringPaginator.pages`. Keyword Args: max_lines (Optional[ :obj:`int` ]): The maximum number of lines per page. Defaults to ``None``, meaning pages will use the ``max_chars`` param instead. max_chars (:obj:`int`): The maximum number of characters per page. Defaults to ``2000``, the max character limit for a discord message. prefix (:obj:`str`): The string to prefix every page with. Defaults to an empty string. suffix (:obj:`str`): The string to suffix every page with. Defaults to an empty string. Example: An example command using pagination to display all the guilds the bot is in. .. code-block:: python from lightbulb.utils.pag import StringPaginator @bot.command() async def guilds(ctx): guilds = await bot.rest.fetch_my_guilds() pag = StringPaginator(max_lines=10) for n, guild in enumerate(guilds, start=1): pag.add_line(f"**{n}.** {guild.name}") for page in pag.build_pages(): await ctx.respond(page) """ __slots__ = () def __init__( self, *, max_lines: t.Optional[int] = None, max_chars: int = 2000, prefix: str = "", suffix: str = "", line_separator: str = "\n", ) -> None: super().__init__( max_lines=max_lines, max_chars=max_chars, prefix=prefix, suffix=suffix, line_separator=line_separator, )
[docs] class EmbedPaginator(Paginator[hikari.Embed]): """ Creates embed pages from lines of text according to the given parameters. Text is added to the paginator the same way as :obj:`~.utils.pag.StringPaginator`. The paginated text will be run though the defined :meth:`embed_factory`, or if no embed factory is defined then it will be inserted into the description of a default embed. Keyword Args: max_lines (Optional[ :obj:`int` ]): The maximum number of lines per page. Defaults to ``None``, meaning pages will use the ``max_chars`` param instead. max_chars (:obj:`int`): The maximum number of characters per page. Defaults to ``2048``, the max character limit for a discord message. prefix (:obj:`str`): The string to prefix every page with. Defaults to an empty string. suffix (:obj:`str`): The string to suffix every page with. Defaults to an empty string. """ __slots__ = () def __init__( self, *, max_lines: t.Optional[int] = None, max_chars: int = 2048, prefix: str = "", suffix: str = "", line_separator: str = "\n", ) -> None: super().__init__( max_lines=max_lines, max_chars=max_chars, prefix=prefix, suffix=suffix, line_separator=line_separator, page_factory=lambda i, s: hikari.Embed(description=s).set_footer(text=f"Page {i}"), )
[docs] def embed_factory(self) -> t.Callable[[t.Callable[[int, str], hikari.Embed]], t.Callable[[int, str], hikari.Embed]]: """ A decorator to mark a function as the paginator's embed factory. The page index and page content will be passed to the function when a new page is to be created. Example: The following code will give each embed created a random colour. .. code-block:: python from random import randint from lightbulb.utils.pag import EmbedPaginator from hikari import Embed pag = EmbedPaginator() @pag.embed_factory() def build_embed(page_index, page_content): return Embed(description=page_content, colour=randint(0, 0xFFFFFF)) See Also: :meth:`set_embed_factory` """ def decorate(func: t.Callable[[int, str], hikari.Embed]) -> t.Callable[[int, str], hikari.Embed]: self.set_embed_factory(func) return func return decorate
[docs] def set_embed_factory(self, func: t.Callable[[int, str], hikari.Embed]) -> None: """ Method to set a callable as the paginator's embed factory. Alternative to :meth:`embed_factory`. Args: func (Callable[ [ :obj:`int`, :obj:`str` ], :obj:`~hikari.embeds.Embed` ]): The callable to set as the paginator's embed factory. Returns: ``None`` See Also: :meth:`embed_factory` """ self._page_factory = func