pygame-menu icon indicating copy to clipboard operation
pygame-menu copied to clipboard

Proper support for multiline labels

Open vnmabus opened this issue 2 years ago • 9 comments

Is your feature request related to a problem? Please describe. Currently real multiline labels are not supported. If one activates word wrapping, several label widgets are created instead, one per line. Thus, it is not possible, for example, to change the text of the label programmatically with word-wrapping, unless you remove every label and re-add them.

Describe the solution you'd like What I want is a widget to display multiline messages. In particular, I want to reimplement the following message display as a pygame_menu widget:

https://user-images.githubusercontent.com/2364173/166908206-eaac1ad9-6a81-48cd-8de1-4ffc2a44929e.mp4

As you can see, this displays multiline messages of variable length, doing word-wrapping and animating the text. This widget can speed up the animation when the enter key is pressed, as well as changing to the next page of text when the enter key is pressed at the end.

If the number of lines exceed the available lines, it should mark it somehow (with an arrow maybe) and allow the user to move the lines upwards / replace existing text to put the new ones (this is currently not implemented).

Describe alternatives you've considered I was trying to implement this as my own widget, when I noticed that the existing Label object partially implements multiline support, but only at creation time. I think that it would be better to implement multiline support in the Label widget directly. The other features could probably be implemented in a Label subclass (or as a composite Widget with a label as one of its members) if they are not deemed general enough.

vnmabus avatar May 05 '22 10:05 vnmabus

This is my first try to implement multiline (in a custom widget):

from typing import List, Optional

import pygame
from pygame.rect import Rect
from pygame_menu._types import EventVectorType
from pygame_menu.utils import make_surface
from pygame_menu.widgets.core.widget import Widget


class TextDisplay(Widget):
    """
    Label widget.

    .. note::

        Label accepts all transformations.

    :param title: Label title/text
    :param label_id: Label ID
    :param onselect: Function when selecting the label widget
    """

    def __init__(
        self,
        title: str,
        text_display_id: str = '',
        wordwrap: bool = True,
        n_lines: Optional[int] = None,
        leading: Optional[int] = None,
    ) -> None:
        super().__init__(
            title=title,
            onselect=None,
            widget_id=text_display_id,
        )
        self._wordwrap = wordwrap
        self._n_lines = n_lines
        self._leading = leading

    def _draw(self, surface: pygame.Surface) -> None:
        # The minimal width of any surface is 1px, so the background will be a
        # line
        if self._title == '':
            return
        assert self._surface
        surface.blit(self._surface, self._rect.topleft)

    def _apply_font(self) -> None:
        return

    def _wordwrap_line(
        self,
        line: str,
        font: pygame.font.Font,
        max_width: int,
        tab_size: int,
    ) -> List[str]:

        final_lines = []
        words = line.split(" ")

        while True:
            split_line = False

            for i, _ in enumerate(words):

                current_line = " ".join(words[:i + 1])
                current_line = current_line.replace("\t", " " * tab_size)
                current_line_size = font.size(current_line)
                if current_line_size[0] > max_width:
                    split_line = True
                    break

            if split_line:
                if i == 0:
                    i += 1
                final_lines.append(" ".join(words[:i]))
                words = words[i:]
            else:
                final_lines.append(current_line)
                break

        return final_lines

    def _get_leading(self) -> int:
        assert self._font

        return (
            self._font.get_linesize()
            if self._leading is None
            else self._leading
        )

    def _get_n_lines(self) -> int:
        assert self._font

        if self._n_lines is None:
            text_size = self._font.get_ascent() + self._font.get_descent()
            leading = self._get_leading()
            offset = leading - text_size

            available_height = self._rect.height

            return (available_height + offset) // (text_size + offset)

        else:
            return self._n_lines

    def _render(self) -> Optional[bool]:
        if not self._render_hash_changed(
            self._title,
            self._font_color,
            self._visible,
        ):
            return None

        # Render surface
        if self._font is None or self._menu is None:
            self._surface = make_surface(
                0,
                0,
                alpha=True,
            )
            return None

        lines = self._title.split("\n")
        if self._wordwrap:
            lines = sum(
                (
                    self._wordwrap_line(
                        line,
                        font=self._font,
                        max_width=self._menu.get_width(inner=True),
                        tab_size=self._tab_size,
                    )
                    for line in lines
                ),
                [],
            )

        self._surface = make_surface(
            max(self._font.size(line)[0] for line in lines),
            len(lines) * self._get_leading(),
            alpha=True,
        )

        for n_line, line in enumerate(lines):
            line_surface = self._render_string(line, self._font_color)
            self._surface.blit(
                line_surface,
                Rect(
                    0,
                    n_line * self._get_leading(),
                    self._rect.width,
                    self._rect.height,
                ),
            )

        self._apply_transforms()
        self._rect.width, self._rect.height = self._surface.get_size()

        self.force_menu_surface_update()
        return True

    def update(self, events: EventVectorType) -> bool:
        self.apply_update_callbacks(events)
        for event in events:
            if self._check_mouseover(event):
                break
        return False

However I did not found a nice way to find the right width (this is the one used by the multiline implementation of Label and it is WRONG).

vnmabus avatar May 06 '22 12:05 vnmabus

What are you refering to get the right width? max(self._font.size(line)[0] for line in lines), do not work?

ppizarror avatar May 06 '22 20:05 ppizarror

You can also create a new PR :). A new widget requires a manager (for menu.add) and lots of new tests.

ppizarror avatar May 06 '22 20:05 ppizarror

What are you refering to get the right width? max(self._font.size(line)[0] for line in lines), do not work?

I meant that using self._menu.get_width(inner=True) overestimates the available space.

vnmabus avatar May 06 '22 21:05 vnmabus

You can also create a new PR :). A new widget requires a manager (for menu.add) and lots of new tests.

I think that at least multiline support could be added to the existing label widget.

vnmabus avatar May 06 '22 21:05 vnmabus

What are you refering to get the right width? max(self._font.size(line)[0] for line in lines), do not work?

I meant that using self._menu.get_width(inner=True) overestimates the available space.

Got it! You can take a look at this example: https://github.com/ppizarror/pygame-menu/blob/685a2941bc940f0fad0d266700f95896ae50310a/pygame_menu/widgets/widget/textinput.py#L610-L629 Textinput also checks the available space of the menu (within its column). Maybe you could use some of these functions.

I this case, _get_max_container_width() returns the max width of the column the widget resides.

ppizarror avatar May 07 '22 16:05 ppizarror

I this case, _get_max_container_width() returns the max width of the column the widget resides.

Tried it, it still uses menu.get_width(inner=True) in my case, which is wrong.

vnmabus avatar May 12 '22 16:05 vnmabus

Hi. I've implemented this feature. See #413 and let me know what you think 😄

ppizarror avatar May 15 '22 04:05 ppizarror

Usage: label = menu.add.label('lorem ipsum dolor sit amet this was very important nice a test is required', wordwrap=True)

ppizarror avatar May 15 '22 05:05 ppizarror

Today I uploaded v4.3.0 to PyPI which incorporates multiline labels. I'll close this issue by now. If anyone has more suggestions, please create a new issue 😄

ppizarror avatar Dec 05 '22 22:12 ppizarror