Source code for gale.ui.text_box

"""
This file contains the implementation of the classes TextBox, a
paginated block of text advanced by click/Enter (RPG-dialogue style),
and PaginatedTextBox, the same paginated text but navigated with
explicit Previous/Next buttons instead.

Author: Alejandro Mujica (aledrums@gmail.com)
"""

from typing import Callable, List, Optional, Tuple

import pygame

from gale.input_handler import MouseClickData
from gale.text import Text

from .button import Button
from .container import Container
from .theme import Theme, get_default_theme
from .widget import Widget


[docs] class TextBox(Widget): """ A paginated block of text, such as dialogue or an in-game hint. advance() moves to the next page (on a mouse click or on_confirm), or, on the last page, hides the box and calls on_close. This is the click-or-Enter-to-continue style used by RPG dialogue, where the player doesn't need to navigate backward. For content the player pages through with explicit "Previous"/"Next" buttons instead (e.g. a rules or help screen), use next_page() and previous_page() directly, or wrap this in a PaginatedTextBox, which also wires up and enables/disables those buttons for you. """ def __init__( self, x: float, y: float, width: float, height: float, text: str, font: Optional[pygame.font.Font] = None, lines_per_page: int = 3, on_close: Optional[Callable[[], None]] = None, theme: Optional[Theme] = None, ) -> None: """ :param x: The box's x position. :param y: The box's y position. :param width: The box's width, used to word-wrap the text. :param height: The box's height. :param text: The full text to page through. :param font: The font used to render and measure the text. The default value is None, so theme.font is used. :param lines_per_page: How many wrapped lines are shown per page. The default value is 3. :param on_close: Called with no arguments once the last page is advanced past. The default value is None. :param theme: This widget's own theme. The default value is None. """ super().__init__(x, y, width, height, theme=theme) self._font = font self.lines_per_page: int = lines_per_page self.on_close: Optional[Callable[[], None]] = on_close self._pages: List[List[str]] = self._paginate(text) self.page_index: int = 0 def _paginate(self, text: str) -> List[List[str]]: font = self._font if self._font is not None else self.theme.font wrapped = self._wrap(text, font) pages = [ wrapped[i : i + self.lines_per_page] for i in range(0, len(wrapped), self.lines_per_page) ] return pages or [[]] def _wrap(self, text: str, font: pygame.font.Font) -> List[str]: max_width = self.width - 2 * self.theme.padding lines: List[str] = [] for paragraph in text.split("\n"): current = "" for word in paragraph.split(" "): candidate = f"{current} {word}".strip() if current and font.size(candidate)[0] > max_width: lines.append(current) current = word else: current = candidate lines.append(current) return lines @property def finished(self) -> bool: return self.page_index >= len(self._pages) - 1 @property def page_count(self) -> int: return len(self._pages) @property def has_next_page(self) -> bool: return self.page_index < len(self._pages) - 1 @property def has_previous_page(self) -> bool: return self.page_index > 0
[docs] def advance(self) -> None: if self.finished: self.visible = False if self.on_close is not None: self.on_close() return self.page_index += 1
[docs] def next_page(self) -> bool: """ Move to the next page, if any, without the auto-close behavior of advance(). Meant to back a "Next" button. :returns: Whether there was a next page to move to. """ if not self.has_next_page: return False self.page_index += 1 return True
[docs] def previous_page(self) -> bool: """ Move back to the previous page, if any. Meant to back a "Previous" button. :returns: Whether there was a previous page to move back to. """ if not self.has_previous_page: return False self.page_index -= 1 return True
[docs] def render(self, surface: pygame.Surface) -> None: if not self.visible: return pygame.draw.rect(surface, self.theme.background_color, self.rect) if self.theme.border_width > 0: pygame.draw.rect( surface, self.theme.border_color, self.rect, self.theme.border_width ) font = self._font if self._font is not None else self.theme.font line_height = font.get_linesize() for i, line in enumerate(self._pages[self.page_index]): text_obj = Text( line, font, self.x + self.theme.padding, self.y + self.theme.padding + i * line_height, self.theme.text_color, ) text_obj.render(surface)
[docs] def on_mouse_click( self, position: Tuple[float, float], data: MouseClickData ) -> bool: if not self.enabled or not self.contains(position): return False if data.released: self.advance() return True
[docs] def on_confirm(self) -> bool: if not self.enabled: return False self.advance() return True
[docs] class PaginatedTextBox(Container): """ A TextBox paired with "Previous"/"Next" buttons docked under it, for content the player pages through at their own pace instead of a click/Enter-to-continue dialogue (e.g. an instructions or help screen). The buttons are kept in sync every update(): disabled on the first/last page instead of wrapping around, and this never auto-hides itself the way TextBox.advance() does on its own — close it explicitly (e.g. from a Window's close button) if needed. Usage example: help_box = PaginatedTextBox(160, 90, 320, 220, long_help_text) """ def __init__( self, x: float, y: float, width: float, height: float, text: str, font: Optional[pygame.font.Font] = None, lines_per_page: int = 3, button_height: int = 28, button_width: int = 96, previous_label: str = "Previous", next_label: str = "Next", theme: Optional[Theme] = None, visible: bool = True, ) -> None: """ :param x: The widget's x position. :param y: The widget's y position. :param width: The widget's width, used to word-wrap the text and lay out the buttons. :param height: The widget's total height, shared between the text box and the buttons row. :param text: The full text to page through. :param font: The font used to render and measure the text. The default value is None, so theme.font is used. :param lines_per_page: How many wrapped lines are shown per page. The default value is 3. :param button_height: The height of the Previous/Next buttons, in pixels. The default value is 28. :param button_width: The width of the Previous/Next buttons, in pixels. The default value is 96. :param previous_label: The label of the "go back" button. The default value is "Previous". :param next_label: The label of the "go forward" button. The default value is "Next". :param theme: This widget's own theme, also handed down to the text box and buttons unless they're given their own. The default value is None. :param visible: Whether the widget (and everything in it) is drawn/updated/reachable by input. The default value is True. """ theme_obj = theme if theme is not None else get_default_theme() padding = theme_obj.padding text_box_height = height - button_height - padding self.text_box = TextBox( x, y, width, text_box_height, text, font=font, lines_per_page=lines_per_page, theme=theme, ) self.previous_button = Button( x, y + text_box_height + padding, button_width, button_height, previous_label, on_click=self._go_previous, theme=theme, ) self.next_button = Button( x + width - button_width, y + text_box_height + padding, button_width, button_height, next_label, on_click=self._go_next, theme=theme, ) super().__init__( x, y, width, height, children=[self.text_box, self.previous_button, self.next_button], theme=theme, visible=visible, ) self._sync_buttons() def _go_previous(self) -> None: self.text_box.previous_page() self._sync_buttons() def _go_next(self) -> None: self.text_box.next_page() self._sync_buttons() def _sync_buttons(self) -> None: self.previous_button.enabled = self.text_box.has_previous_page self.next_button.enabled = self.text_box.has_next_page
[docs] def update(self, dt: float) -> None: super().update(dt) self._sync_buttons()