diff --git a/CHANGELOG.md b/CHANGELOG.md index 13a686423..3ebfd3520 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,11 @@ Arcade [PyPi Release History](https://pypi.org/project/arcade/#history) page. - Text sprites went blank when the texture atlas rebuilt itself, which happens when it fills up after some textures were freed. The atlas redraws textures from their images, and a text sprite's image was empty. `sprite.texture.image` now also contains the text. - Fixed an `AttributeError` being printed as "Exception ignored in `__del__`" when creating an OpenGL texture, framebuffer, buffer, program, compute shader, texture array, or vertex array failed, for example with an invalid number of texture components. - Fixed `arcade.draw_text()` cache problems: a multiline call and an otherwise equal single-line call shared one label and drew wrong, and animating `rotation` or `font_size` added a cached label every frame, forever. The cache now holds at most 256 labels, forgetting the least recently used. +- Fixed `arcade.Text` style properties: + - Setting `italic` after creating the text had no visible effect. `"oblique"` is now also accepted. + - `bold` strings such as `"light"` or `"semibold"` were all treated as bold, including `"normal"`. They now set that font weight, and the `bold` property returns the weight name for weights other than bold and normal. + - A `pyglet.text.LinearGradient` color raised `TypeError` in `Text`, `draw_text()`, `create_text_sprite()`, and `UILabel.update_font()`. Gradients now work, and the `color` property returns the gradient. + - The `Text` constructor ignored every error while creating the label, so bad arguments such as an unknown keyword or an invalid `font_name` only raised later, the first time the text was used. Only the "no window yet" case is deferred now. ### New Features - Added `arcade.sweep_sprite(sprite, dx, dy, sprite_list)`, which checks the whole path of a moving sprite and returns a `SweepInfo` for the first sprite it would hit (the `sprite`, how far along the move as a `fraction` and `distance`, and the surface `normal`), or `None`. Fast sprites can't pass through thin walls this way. A sprite that already overlaps one is an immediate hit. Added the `sprite_bullets_sweep` example comparing it with a plain collision check. diff --git a/arcade/gui/widgets/text.py b/arcade/gui/widgets/text.py index cce3502ed..ae64b0914 100644 --- a/arcade/gui/widgets/text.py +++ b/arcade/gui/widgets/text.py @@ -6,6 +6,7 @@ import pyglet from pyglet.event import EVENT_HANDLED, EVENT_UNHANDLED from pyglet.text.caret import Caret +from pyglet.text import LinearGradient from pyglet.text.document import AbstractDocument from typing_extensions import override @@ -30,7 +31,7 @@ from arcade.gui.surface import Surface from arcade.gui.widgets import UIInteractiveWidget, UIWidget from arcade.gui.widgets.layout import UIAnchorLayout -from arcade.text import FontNameOrNames +from arcade.text import FontNameOrNames, _to_text_color from arcade.types import LBWH, RGBA255, Color, RGBOrA255 @@ -216,7 +217,7 @@ def font_size(self) -> float: return self._label.font_size @property - def font_color(self) -> Color: + def font_color(self) -> Color | LinearGradient: """Font color of the label. Use :py:meth:`~arcade.gui.UILabel.update_font` to change.""" return self._label.color @@ -262,9 +263,9 @@ def update_font( self, font_name: FontNameOrNames | None = None, font_size: float | None = None, - font_color: Color | None = None, + font_color: RGBOrA255 | LinearGradient | None = None, bold: bool | str | None = None, - italic: bool | None = None, + italic: bool | str | None = None, ): """Update font of the label. @@ -273,20 +274,23 @@ def update_font( beginning of the tuple and keep trying to load fonts until success. font_size: Font size of font. - font_color: Color of the text. - bold: May be any value in :py:obj:`pyglet.text.Weight`, + font_color: Color of the text, or a + :py:class:`pyglet.text.LinearGradient`. + bold: May be any value in :py:class:`pyglet.enums.Weight`, ``True`` (converts to ``"bold"``), or ``False`` - (converts to ``"regular"``). + (converts to ``"normal"``). italic: If enabled, the label's text will be in an *italic* + style. May also be ``"oblique"``. """ font_name = font_name or self._requested_font_name font_size = font_size or self._label.font_size - font_color = font_color or self._label.color + if font_color is None: + font_color = self._label.color font_bold = bold if bold is not None else self._label.bold font_italic = italic if italic is not None else self._label.italic - # ensure type of font_color, label will allways be a color - font_color = Color.from_iterable(font_color) + # The label holds a Color or a gradient, so compare with the same type + font_color = _to_text_color(font_color) # Check if values actually changed, if then update and trigger render. # The label holds the resolved font name (e.g. "arial" for diff --git a/arcade/text.py b/arcade/text.py index 88f0eee34..521b7da04 100644 --- a/arcade/text.py +++ b/arcade/text.py @@ -3,6 +3,7 @@ """ import math +from enum import Enum from pathlib import Path from typing import Any from uuid import uuid4 @@ -13,9 +14,10 @@ # Pyright can't figure out the dynamic backend imports in pyglet.graphics # right now. Maybe can fix in future Pyglet version from pyglet.graphics import Batch, Group # type: ignore +from pyglet.text import LinearGradient import arcade -from arcade.exceptions import PerformanceWarning, warning +from arcade.exceptions import NoArcadeWindowError, PerformanceWarning, warning from arcade.resources import resolve from arcade.texture_atlas import TextureAtlasBase from arcade.types import Color, Point, RGBOrA255 @@ -108,6 +110,49 @@ def _draw_pyglet_label(label: pyglet.text.Label) -> None: label.draw() +def _to_text_color(color: RGBOrA255 | LinearGradient) -> Color | LinearGradient: + """Convert a text color to a Color, passing gradients through unchanged.""" + if isinstance(color, LinearGradient): + return color + return Color.from_iterable(color) + + +def _to_weight(bold: bool | str) -> str: + """Convert a ``bold`` value to a pyglet font weight name.""" + if isinstance(bold, Enum): + return bold.value + if isinstance(bold, str) and bold: + return bold + return (Weight.BOLD if bold else Weight.NORMAL).value + + +def _from_weight(weight: Any) -> bool | str: + """Convert a pyglet font weight back to a ``bold`` value.""" + if weight == Weight.BOLD.value: + return True + if weight is None or weight == Weight.NORMAL.value: + return False + return str(weight) + + +def _to_style(italic: bool | str) -> str: + """Convert an ``italic`` value to a pyglet font style name.""" + if isinstance(italic, Enum): + return italic.value + if isinstance(italic, str) and italic: + return italic + return (Style.ITALIC if italic else Style.NORMAL).value + + +def _from_style(style: Any) -> bool | str: + """Convert a pyglet font style back to an ``italic`` value.""" + if style == Style.ITALIC.value: + return True + if style is None or style == Style.NORMAL.value: + return False + return str(style) + + class Text: """ An object-oriented way to draw text to the screen. @@ -155,7 +200,8 @@ class Text: y: y position to align the text's anchor point with z: z position to align the text's anchor point with color: Color of the text as an RGBA tuple or a - :py:class:`~arcade.types.Color` instance. + :py:class:`~arcade.types.Color` instance, or a + :py:class:`pyglet.text.LinearGradient` for a left-to-right gradient. font_size: Size of the text in points width: A width limit in pixels align: Horizontal alignment; values other than "left" require width to be set. @@ -211,13 +257,13 @@ def __init__( text: str, x: float, y: float, - color: RGBOrA255 = arcade.color.WHITE, + color: RGBOrA255 | LinearGradient = arcade.color.WHITE, font_size: float = 12, width: int | None = None, align: str = "left", font_name: FontNameOrNames = ("calibri", "arial"), bold: bool | str = False, - italic: bool = False, + italic: bool | str = False, anchor_x: str = "left", anchor_y: str = "baseline", multiline: bool = False, @@ -231,13 +277,13 @@ def __init__( text=text, x=x, y=y, - color=Color.from_iterable(color), + color=_to_text_color(color), font_size=font_size, width=width, align=align, font_name=font_name, - weight=Weight.BOLD if bold else Weight.NORMAL, - style=Style.ITALIC if italic else Style.NORMAL, + weight=_to_weight(bold), + style=_to_style(italic), anchor_x=anchor_x, anchor_y=anchor_y, multiline=multiline, @@ -260,7 +306,8 @@ def __init__( self._initialized = False try: self._init_deferred() - except Exception: + except NoArcadeWindowError: + # No window yet, so create the label when it's first used pass @property @@ -456,13 +503,21 @@ def rotation(self, rotation: float): self.label.rotation = rotation @property - def color(self) -> Color: - """Get or set the text color for the label.""" - return Color.from_iterable(self.label.color) + def color(self) -> Color | LinearGradient: + """ + Get or set the text color for the label. + + This is a :py:class:`~arcade.types.Color`, or a + :py:class:`pyglet.text.LinearGradient` if one was set. + """ + color = self.label.color + if isinstance(color, LinearGradient): + return color + return Color.from_iterable(color) @color.setter - def color(self, color: RGBOrA255): - self.label.color = Color.from_iterable(color) + def color(self, color: RGBOrA255 | LinearGradient): + self.label.color = _to_text_color(color) @property def width(self) -> int | None: @@ -563,29 +618,41 @@ def bold(self) -> bool | str: """ Get or set bold state of the label. - The supported values include: - - * ``"black"`` - * ``"bold" (same as ``True``) - * ``"semibold"`` - * ``"semilight"`` - * ``"light"`` + ``True`` is the same as ``"bold"``, and ``False`` the same as + ``"normal"``. Other values are font weight names from + :py:class:`pyglet.enums.Weight`, such as ``"thin"``, + ``"light"``, ``"medium"``, ``"semibold"``, ``"extrabold"`` + or ``"black"``. Not every font has every weight. + Returns ``True`` for bold, ``False`` for normal, and the weight + name for any other weight. """ - return self.label.weight == Weight.BOLD + return _from_weight(self.label.weight) @bold.setter def bold(self, bold: bool | str): - self.label.weight = Weight.BOLD if bold else Weight.NORMAL + self.label.weight = _to_weight(bold) @property def italic(self) -> bool | str: - """Get or set the italic state of the label.""" - return self.label.italic + """ + Get or set the italic state of the label. + + ``True`` is the same as ``"italic"``, and ``False`` the same as + ``"normal"``. ``"oblique"`` is also accepted. + + Returns ``True`` for italic, ``False`` for normal, and the style + name for any other style. + """ + return _from_style(self.label.document.get_style("style")) @italic.setter def italic(self, italic: bool | str): - self.label.italic = italic + # Set the "style" document style, which pyglet uses to pick the + # font. pyglet's own Label.italic sets an "italic" style, which + # doesn't change the font. + label = self.label + label.document.set_style(0, len(label.document.text), {"style": _to_style(italic)}) @property def multiline(self) -> bool: @@ -751,7 +818,7 @@ def draw( text: str, x: float, y: float, - color: RGBOrA255 = arcade.color.WHITE, + color: RGBOrA255 | LinearGradient = arcade.color.WHITE, font_size: float = 12, **kwargs, ) -> Text: @@ -786,7 +853,7 @@ def get( text: str, x: float, y: float, - color: RGBOrA255 = arcade.color.WHITE, + color: RGBOrA255 | LinearGradient = arcade.color.WHITE, font_size: float = 12, **kwargs, ) -> Text: @@ -852,7 +919,7 @@ def remove(self, key: str) -> None: def create_text_sprite( text: str, - color: RGBOrA255 = arcade.color.WHITE, + color: RGBOrA255 | LinearGradient = arcade.color.WHITE, font_size: float = 12.0, width: int | None = None, align: str = "left", @@ -882,7 +949,8 @@ def create_text_sprite( Args: text: Initial text to display. Can be an empty string color: Color of the text as an RGBA tuple or a - :py:class:`~arcade.types.Color` instance. + :py:class:`~arcade.types.Color` instance, or a + :py:class:`pyglet.text.LinearGradient` for a left-to-right gradient. font_size: Size of the text in points width: A width limit in pixels align: Horizontal alignment; values other than "left" require width to be set. @@ -1000,7 +1068,7 @@ def draw_text( text: Any, x: float, y: float, - color: RGBOrA255 = arcade.color.WHITE, + color: RGBOrA255 | LinearGradient = arcade.color.WHITE, font_size: float = 12.0, width: int | None = None, align: str = "left", @@ -1041,7 +1109,8 @@ def draw_text( y: y position to align the text's anchor point with z: z position to align the text's anchor point with color: Color of the text as an RGBA tuple or a - :py:class:`~arcade.types.Color` instance. + :py:class:`~arcade.types.Color` instance, or a + :py:class:`pyglet.text.LinearGradient` for a left-to-right gradient. font_size: Size of the text in points width: A width limit in pixels align: Horizontal alignment; values other than "left" require width to be set. @@ -1185,7 +1254,7 @@ def draw_text( f"but got {width!r}." ) - color = Color.from_iterable(color) + color = _to_text_color(color) text = str(text) # Reuse labels, keyed by the settings that are expensive to change and # the text. Position, color, and rotation are cheap to update. diff --git a/tests/unit/gui/test_uilabel.py b/tests/unit/gui/test_uilabel.py index 5a85ce003..c9c9549d2 100644 --- a/tests/unit/gui/test_uilabel.py +++ b/tests/unit/gui/test_uilabel.py @@ -1,6 +1,7 @@ from unittest.mock import Mock from pyglet.math import Vec2 +from pyglet.text import LinearGradient from arcade.gui import UILabel from arcade.types import Color, LBWH @@ -259,3 +260,26 @@ def test_fit_content_uses_adaptive_multiline_width(ui): # check size_hint_min updated assert label.size_hint_min[0] > shm_w assert label.size_hint_min[1] < shm_h + + +def test_update_font_italic_bold_and_gradient(window): + label = UILabel(text="Example", font_name="Liberation Sans") + gradient = LinearGradient((255, 0, 0, 255), (0, 0, 255, 255)) + + label.update_font(italic=True, bold="light", font_color=gradient) + + assert label.italic is True + assert label.bold == "light" + assert label.font_color == gradient + assert label._label.label.document.get_style("style") == "italic" + + # Re-applying the same values is not a change + label._requires_render = False + label.update_font(italic=True, bold="light", font_color=gradient) + assert label._requires_render is False + + # Plain colors still become a Color + label.update_font(italic=False, bold=False, font_color=(0, 255, 0)) + assert label.italic is False + assert label.bold is False + assert label.font_color == Color(0, 255, 0, 255) diff --git a/tests/unit/text/test_text_style.py b/tests/unit/text/test_text_style.py new file mode 100644 index 000000000..921dcfbac --- /dev/null +++ b/tests/unit/text/test_text_style.py @@ -0,0 +1,161 @@ +"""Tests for the Text color, bold, and italic properties, and constructor errors""" + +import pytest +from pyglet.enums import Style, Weight +from pyglet.text import LinearGradient + +import arcade +from arcade.exceptions import NoArcadeWindowError + +FONT = "Liberation Sans" +GRADIENT = LinearGradient((255, 0, 0, 255), (0, 0, 255, 255)) + + +def _render(window, text: arcade.Text): + """Draw a Text into an offscreen framebuffer and return its pixels""" + ctx = window.ctx + fbo = ctx.framebuffer(color_attachments=[ctx.texture((400, 100), components=4)]) + with fbo.activate(): + fbo.clear(color=(0, 0, 0, 0)) + text.draw() + return fbo.read(components=4) + + +def _make(**kwargs) -> arcade.Text: + return arcade.Text("Hello World", 10, 30, font_name=FONT, font_size=30, **kwargs) + + +def test_italic_setter_changes_font(window): + normal = _render(window, _make()) + italic = _render(window, _make(italic=True)) + assert normal != italic + + text = _make() + text.italic = True + assert text.italic is True + assert _render(window, text) == italic + + text.italic = False + assert text.italic is False + assert _render(window, text) == normal + + +@pytest.mark.parametrize( + ("value", "expected"), + ( + (False, False), + (True, True), + ("normal", False), + ("italic", True), + (Style.ITALIC, True), + ("oblique", "oblique"), + ), +) +def test_italic_values(window, value, expected): + assert _make(italic=value).italic == expected + + text = _make() + text.italic = value + assert text.italic == expected + + +@pytest.mark.parametrize( + ("value", "expected_bold", "expected_weight"), + ( + (False, False, "normal"), + (True, True, "bold"), + ("normal", False, "normal"), + ("bold", True, "bold"), + (Weight.BOLD, True, "bold"), + ("light", "light", "light"), + ("semibold", "semibold", "semibold"), + (Weight.BLACK, "black", "black"), + ), +) +def test_bold_values(window, value, expected_bold, expected_weight): + text = _make(bold=value) + assert text.bold == expected_bold + assert text.label.weight == expected_weight + + text = _make() + text.bold = value + assert text.bold == expected_bold + assert text.label.weight == expected_weight + + +def test_bold_normal_string_draws_normal(window): + """bold="normal" used to be treated as True, since it's a non-empty string""" + normal = _render(window, _make()) + assert _render(window, _make(bold="normal")) == normal + assert _render(window, _make(bold=True)) != normal + + +def test_gradient_color(window): + text = _make(color=GRADIENT) + assert text.color == GRADIENT + + text = _make() + text.color = GRADIENT + assert text.color == GRADIENT + assert text.label.color == GRADIENT + + # Setting a plain color again works, and returns a Color + text.color = (0, 255, 0) + assert text.color == arcade.types.Color(0, 255, 0, 255) + assert isinstance(text.color, arcade.types.Color) + + +def _red_blue_totals(pixels): + """Sum of red in the left half and blue in the right half, and the reverse""" + width = 400 + left_red = right_red = left_blue = right_blue = 0 + for i in range(0, len(pixels), 4): + x = (i // 4) % width + if x < width // 2: + left_red += pixels[i] + left_blue += pixels[i + 2] + else: + right_red += pixels[i] + right_blue += pixels[i + 2] + return left_red, right_red, left_blue, right_blue + + +def test_gradient_draws_left_to_right(window): + pixels = _render(window, _make(color=GRADIENT, width=380, align="center")) + left_red, right_red, left_blue, right_blue = _red_blue_totals(pixels) + assert left_red > right_red + assert right_blue > left_blue + + +# Tests draw_text, which warns that it's slow +@pytest.mark.filterwarnings("ignore::arcade.exceptions.PerformanceWarning") +def test_draw_text_gradient(window): + window.ctx.label_cache.clear() + arcade.draw_text("Hello", 10, 10, color=GRADIENT) + arcade.draw_text("Hello", 10, 10, color=arcade.color.RED) + arcade.draw_text("Hello", 10, 10, color=GRADIENT) + (label,) = window.ctx.label_cache.values() + assert label.color == GRADIENT + + +def test_bad_arguments_raise_in_constructor(window): + """These used to be swallowed, then raised later when the text was first used""" + with pytest.raises(TypeError): + arcade.Text("Hello", 0, 0, font_name=123) # type: ignore + + +def test_lazy_init_without_window(window, monkeypatch): + """Creating a Text before there's a window still works, and creates the label later""" + + def no_window(): + raise NoArcadeWindowError("No window") + + with monkeypatch.context() as patch: + patch.setattr(arcade, "get_window", no_window) + text = arcade.Text("Hello", 0, 0, italic=True, bold="light", color=GRADIENT) + assert not text._initialized + + assert text.italic is True + assert text.bold == "light" + assert text.color == GRADIENT + assert text._initialized