From 1c3ef59dca8ea48eeb9aaf77806fa88415f6be6a Mon Sep 17 00:00:00 2001 From: Paul V Craven Date: Tue, 6 Oct 2026 09:07:55 -0500 Subject: [PATCH] Fix Text italic, bold strings, gradient colors, and swallowed errors - Setting Text.italic now changes the font. It sets pyglet's "style" document style, which picks the font, instead of Label.italic, which sets an "italic" style that doesn't. "oblique" is also accepted. - bold strings such as "light" or "semibold" set that weight instead of all being treated as bold, including "normal". The bold getter returns the weight name for weights other than bold and normal. - pyglet LinearGradient colors work in Text, draw_text, create_text_sprite, and UILabel.update_font, and the color getter returns the gradient. This also fixes the mypy error in text.py. - The Text constructor only defers label creation when there is no window yet, so bad arguments raise right away. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 5 + arcade/gui/widgets/text.py | 24 +++-- arcade/text.py | 133 ++++++++++++++++++------ tests/unit/gui/test_uilabel.py | 24 +++++ tests/unit/text/test_text_style.py | 161 +++++++++++++++++++++++++++++ 5 files changed, 305 insertions(+), 42 deletions(-) create mode 100644 tests/unit/text/test_text_style.py 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